| 1 | //! Complimentary and custom terms: g1t staff enroll a workspace by hand in |
| 2 | //! sudo (`admin_set_complimentary`); nothing enrolls a workspace on its own, |
| 3 | //! and new workspaces stay free. |
| 4 | //! |
| 5 | //! **What it gives.** The plan's features; a monthly usage allowance valued |
| 6 | //! at ordinary usage prices (not at cost, and not a discount); and a |
| 7 | //! recurring monthly fee of its own, often waived. The fee and the |
| 8 | //! allowance are separate amounts. Calendar months in UTC, no rollover. |
| 9 | //! |
| 10 | //! **Where it stops.** Past the allowance new billable work is refused |
| 11 | //! (`reserve`, and `has_plan` false for storage and caches). There is never |
| 12 | //! an overage, a saved card or not. Work already running when the allowance |
| 13 | //! ran out finishes; what it cost past the allowance is g1t's |
| 14 | //! (`ledger.given_micros`), recorded apart from the allowance |
| 15 | //! (`ledger.gift_micros`) and never charged afterwards. Admission counts the |
| 16 | //! month's used allowance, usage metered but not on the ledger yet (storage, |
| 17 | //! git operations) and what running work holds. |
| 18 | //! |
| 19 | //! **Periods.** Terms are kept as periods (`complimentary_periods`). A usage |
| 20 | //! line draws on the period covering its own time (`allocate_usage.sql`), so |
| 21 | //! late usage lands where it happened. Changing the amount closes one period |
| 22 | //! and opens the next; consumption is per month, so it is kept, and an amount |
| 23 | //! lowered below it leaves nothing more this month, never a debt. |
| 24 | //! |
| 25 | //! **Stripe.** A real customer and subscription stand for the enrollment, |
| 26 | //! with the grant's identity and terms in its metadata. The workspace sees |
| 27 | //! the enrollment only once Stripe has the subscription: until then it is |
| 28 | //! `pending` and keeps its plan. Each Stripe step is written down first |
| 29 | //! (`pending_op`) with an idempotency key made from the grant, so a retry |
| 30 | //! after a crash or a timeout repeats the same request; one older than |
| 31 | //! Stripe keeps keys for is reconciled by looking for the grant at Stripe |
| 32 | //! instead, never sent again under a new key. The fifteen-minute tick picks |
| 33 | //! up any step still owed. A waived fee is a $0 price on the plan's |
| 34 | //! product; g1t's own unlimited enrollment (flagon-io) is the plan's price |
| 35 | //! with a 100% coupon, a fully discounted real subscription. |
| 36 | //! |
| 37 | //! The allowance is never a ledger top-up: it is not money and not credit |
| 38 | //! on the balance. Money received (plan and fee invoices, payments in |
| 39 | //! advance, bank transfers) and promotional credit are shown apart. |
| 40 | //! |
| 41 | //! **Converting.** An owner can start the plan on the same customer and |
| 42 | //! subscription (`convert_to_paid`): its one item becomes the plan's price |
| 43 | //! with `payment_behavior=pending_if_incomplete`, so Stripe changes nothing |
| 44 | //! until the first payment succeeds. A decline, a bank asking for the person |
| 45 | //! (3DS), an abandoned page or a second click leave the enrollment as it |
| 46 | //! was, with one subscription. Only once Stripe shows the plan's price on |
| 47 | //! the item does the enrollment end (`converted`). Usage up to that moment |
| 48 | //! stays as the allowance paid for it. |
| 49 | |
| 50 | use g1t_contracts::billing::{ |
| 51 | AdminComplimentaryArgs, AdminEndComplimentaryArgs, AdminSetComplimentaryArgs, CardCheckArgs, ComplimentaryMonth, ComplimentaryPeriod, |
| 52 | ComplimentaryStanding, Conversion, ConvertToPaidArgs, Feature, UsageAlert, MAX_COMPLIMENTARY_FEE_CENTS, MAX_COMPLIMENTARY_MICROS, |
| 53 | }; |
| 54 | use g1t_contracts::time::rfc3339; |
| 55 | use g1t_contracts::{FailureCode, Outcome, new_id}; |
| 56 | use g1t_kit::now_ms; |
| 57 | use serde::{Deserialize, Serialize}; |
| 58 | use worker::Result; |
| 59 | |
| 60 | use crate::Billing; |
| 61 | use crate::features::{cents, dollars}; |
| 62 | use crate::stripe::{Stripe, StripeSubscription, is_missing}; |
| 63 | |
| 64 | /// Stripe keeps an idempotency key for 24 hours. An operation older than |
| 65 | /// this is reconciled by looking for what it made, never sent again. |
| 66 | const KEY_KEPT_MS: u64 = 23 * 60 * 60 * 1000; |
| 67 | /// The coupon behind a fully discounted enrollment (g1t's own). |
| 68 | pub(crate) const FULL_DISCOUNT_COUPON: &str = "g1t_complimentary_100"; |
| 69 | /// Pending Stripe steps tried each tick. |
| 70 | const TICK_BATCH: u32 = 10; |
| 71 | |
| 72 | #[derive(Clone, Debug, Deserialize)] |
| 73 | pub(crate) struct Row { |
| 74 | pub workspace: String, |
| 75 | pub grant_id: String, |
| 76 | pub status: String, |
| 77 | pub allowance_micros: i64, |
| 78 | pub unlimited: i64, |
| 79 | pub fee_cents: i64, |
| 80 | /// `stop` or `charge`: what happens past the allowance. |
| 81 | pub overage: String, |
| 82 | pub live: i64, |
| 83 | pub customer_id: Option<String>, |
| 84 | pub subscription_id: Option<String>, |
| 85 | pub item_id: Option<String>, |
| 86 | pub price_id: Option<String>, |
| 87 | pub stripe_status: Option<String>, |
| 88 | pub pending_op: Option<String>, |
| 89 | pub last_error: Option<String>, |
| 90 | pub note: String, |
| 91 | pub created_by: String, |
| 92 | pub created_at: String, |
| 93 | pub created_ms: i64, |
| 94 | pub updated_by: String, |
| 95 | pub updated_at: String, |
| 96 | pub ended_at: Option<String>, |
| 97 | pub ended_reason: Option<String>, |
| 98 | } |
| 99 | |
| 100 | impl Row { |
| 101 | fn unlimited(&self) -> bool { |
| 102 | self.unlimited != 0 |
| 103 | } |
| 104 | |
| 105 | /// Past the allowance, the workspace's own card is charged. |
| 106 | pub(crate) fn charges_past(&self) -> bool { |
| 107 | self.overage == "charge" |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | /// Fully discounted: the plan's price under the 100% coupon. Only g1t's own |
| 112 | /// workspaces (a 100% discount in their terms) with an unlimited allowance |
| 113 | /// and no fee are; any other enrollment is its own price. |
| 114 | pub(crate) fn fully_discounted(row: &Row, internal: bool) -> bool { |
| 115 | internal && row.unlimited() && row.fee_cents == 0 |
| 116 | } |
| 117 | |
| 118 | /// A Stripe step still owed. |
| 119 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] |
| 120 | #[serde(tag = "op", rename_all = "snake_case")] |
| 121 | pub(crate) enum Op { |
| 122 | /// Make the customer (if needed) and the subscription. |
| 123 | Create, |
| 124 | /// Bring the subscription's price and metadata to the open period's. |
| 125 | Terms { period: String }, |
| 126 | /// Cancel the subscription now. |
| 127 | Cancel, |
| 128 | /// The owner's conversion to the plan, attempt `attempt` started at |
| 129 | /// `started_ms` (both in its idempotency keys, so a refused attempt is |
| 130 | /// never replayed by the next). |
| 131 | Convert { attempt: u32, by: String, started_ms: u64 }, |
| 132 | } |
| 133 | |
| 134 | impl Op { |
| 135 | pub(crate) fn parse(text: Option<&str>) -> Option<Op> { |
| 136 | serde_json::from_str(text?).ok() |
| 137 | } |
| 138 | |
| 139 | fn text(&self) -> String { |
| 140 | serde_json::to_string(self).unwrap_or_default() |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | /// Why staff's terms cannot be saved, or None. |
| 145 | pub(crate) fn invalid(a: &AdminSetComplimentaryArgs) -> Option<String> { |
| 146 | if a.workspace.trim().is_empty() { |
| 147 | return Some("Name the workspace.".into()); |
| 148 | } |
| 149 | if a.note.trim().len() < 3 { |
| 150 | return Some("Say why, in a few words: it goes in the audit log.".into()); |
| 151 | } |
| 152 | if a.by.trim().is_empty() { |
| 153 | return Some("Who is making the change is missing.".into()); |
| 154 | } |
| 155 | if !(0..=MAX_COMPLIMENTARY_MICROS).contains(&a.allowance_micros) { |
| 156 | return Some(format!("The monthly allowance is between $0 and {}; for more, make it unlimited.", cents(MAX_COMPLIMENTARY_MICROS))); |
| 157 | } |
| 158 | if a.fee_cents > MAX_COMPLIMENTARY_FEE_CENTS { |
| 159 | return Some(format!("The monthly fee is at most {}.", cents(i64::from(MAX_COMPLIMENTARY_FEE_CENTS) * 10_000))); |
| 160 | } |
| 161 | if !a.unlimited && a.allowance_micros == 0 && a.fee_cents == 0 { |
| 162 | return Some("An enrollment with no allowance and no fee gives nothing: set an allowance, or end the enrollment.".into()); |
| 163 | } |
| 164 | None |
| 165 | } |
| 166 | |
| 167 | /// A month's standing from its figures. `remaining` is what new work may |
| 168 | /// still hold: what is left after used, pending and held usage. |
| 169 | pub(crate) fn month_standing(month: &str, row: &Row, used: i64, pending: i64, held: i64, overrun: i64, has_card: bool) -> ComplimentaryMonth { |
| 170 | let remaining = if row.unlimited() { crate::credits::left(g1t_contracts::billing::UNLIMITED_MICROS, used) } else { |
| 171 | crate::credits::left(row.allowance_micros, used + pending.max(0) + held.max(0)) |
| 172 | }; |
| 173 | ComplimentaryMonth { |
| 174 | month: month.to_owned(), |
| 175 | allowance_micros: row.allowance_micros, |
| 176 | unlimited: row.unlimited(), |
| 177 | used_micros: used, |
| 178 | pending_micros: pending.max(0), |
| 179 | held_micros: held.max(0), |
| 180 | remaining_micros: remaining, |
| 181 | overrun_micros: overrun, |
| 182 | resets_at: crate::credits::next_month_start(month), |
| 183 | fee_cents: row.fee_cents.max(0) as u32, |
| 184 | charge_past_allowance: row.charges_past(), |
| 185 | has_card, |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | /// The subscription's metadata: the grant and its terms, so Stripe shows |
| 190 | /// what the $0 (or custom) price stands for. |
| 191 | fn metadata_fields(row: &Row) -> Vec<(&'static str, String)> { |
| 192 | vec![ |
| 193 | ("metadata[workspace]", row.workspace.clone()), |
| 194 | ("metadata[feature]", "complimentary".to_owned()), |
| 195 | ("metadata[g1t_grant]", row.grant_id.clone()), |
| 196 | ("metadata[g1t_allowance_micros]", row.allowance_micros.to_string()), |
| 197 | ("metadata[g1t_unlimited]", row.unlimited().to_string()), |
| 198 | ("metadata[g1t_fee_cents]", row.fee_cents.to_string()), |
| 199 | ("metadata[g1t_past_allowance]", row.overage.clone()), |
| 200 | ] |
| 201 | } |
| 202 | |
| 203 | /// The one item's price: the fee (a $0 price when waived), or the plan's |
| 204 | /// price under the 100% coupon for a fully discounted enrollment. |
| 205 | fn price_fields(row: &Row, product: &str, plan_cents: u32, prefix: &'static str, fully: bool) -> Vec<(String, String)> { |
| 206 | let amount = if fully { i64::from(plan_cents) } else { row.fee_cents.max(0) }; |
| 207 | vec![ |
| 208 | (format!("{prefix}[price_data][currency]"), "usd".to_owned()), |
| 209 | (format!("{prefix}[price_data][product]"), product.to_owned()), |
| 210 | (format!("{prefix}[price_data][unit_amount]"), amount.to_string()), |
| 211 | (format!("{prefix}[price_data][recurring][interval]"), "month".to_owned()), |
| 212 | (format!("{prefix}[price_data][tax_behavior]"), "exclusive".to_owned()), |
| 213 | ] |
| 214 | } |
| 215 | |
| 216 | /// A new enrollment's subscription. A fee is invoiced (sent, payable for 30 |
| 217 | /// days): negotiated terms are not charged to a card nobody gave. A waived |
| 218 | /// fee has nothing to tax or collect; converting turns both on. |
| 219 | pub(crate) fn create_fields(row: &Row, customer: &str, product: &str, plan_cents: u32, fully: bool) -> Vec<(String, String)> { |
| 220 | let mut fields: Vec<(String, String)> = vec![("customer".into(), customer.to_owned())]; |
| 221 | fields.extend(price_fields(row, product, plan_cents, "items[0]", fully)); |
| 222 | fields.extend(metadata_fields(row).into_iter().map(|(k, v)| (k.to_owned(), v))); |
| 223 | fields.push(("description".into(), format!("Complimentary g1t plan for {}", row.workspace))); |
| 224 | if fully { |
| 225 | fields.push(("discounts[0][coupon]".into(), FULL_DISCOUNT_COUPON.to_owned())); |
| 226 | } |
| 227 | if row.fee_cents > 0 { |
| 228 | fields.push(("collection_method".into(), "send_invoice".into())); |
| 229 | fields.push(("days_until_due".into(), "30".into())); |
| 230 | fields.push(("automatic_tax[enabled]".into(), "true".into())); |
| 231 | } |
| 232 | fields |
| 233 | } |
| 234 | |
| 235 | /// Whether Stripe shows the conversion done: an owner's conversion is in |
| 236 | /// progress (`converting`), nothing waits on a payment, and the item now has |
| 237 | /// a new price at the plan's amount. A staff terms change also gives the |
| 238 | /// item a new price, so the price alone never counts. |
| 239 | pub(crate) fn converted(price_id: Option<&str>, subscription: &StripeSubscription, plan_cents: u32, converting: bool) -> bool { |
| 240 | let price = subscription.first_item().and_then(|item| item.price.as_ref()); |
| 241 | converting |
| 242 | && subscription.pending_update.as_ref().is_none_or(serde_json::Value::is_null) |
| 243 | && matches!(subscription.status.as_str(), "active" | "trialing") |
| 244 | && price_id.is_some() |
| 245 | && price.is_some_and(|p| Some(p.id.as_str()) != price_id && p.unit_amount == Some(i64::from(plan_cents))) |
| 246 | } |
| 247 | |
| 248 | fn owned(fields: &[(String, String)]) -> Vec<(&str, String)> { |
| 249 | fields.iter().map(|(k, v)| (k.as_str(), v.clone())).collect() |
| 250 | } |
| 251 | |
| 252 | #[derive(Deserialize)] |
| 253 | struct Sum { |
| 254 | micros: Option<i64>, |
| 255 | } |
| 256 | |
| 257 | impl Billing { |
| 258 | pub(crate) async fn complimentary_row(&self, workspace: &str) -> Result<Option<Row>> { |
| 259 | self.db.prepare("SELECT * FROM complimentary WHERE workspace = ?").bind(&[workspace.into()])?.first::<Row>(None).await |
| 260 | } |
| 261 | |
| 262 | /// The workspace's enrollment, when the workspace has it now. |
| 263 | pub(crate) async fn complimentary_active(&self, workspace: &str) -> Result<Option<Row>> { |
| 264 | Ok(self.complimentary_row(workspace).await?.filter(|row| row.status == "active")) |
| 265 | } |
| 266 | |
| 267 | async fn sum(&self, sql: &str, binds: &[worker::wasm_bindgen::JsValue]) -> Result<i64> { |
| 268 | Ok(self.db.prepare(sql).bind(binds)?.first::<Sum>(None).await?.and_then(|s| s.micros).unwrap_or(0)) |
| 269 | } |
| 270 | |
| 271 | /// This month's standing of an active enrollment. `held` is what open |
| 272 | /// reservations hold, when the caller has read it. |
| 273 | pub(crate) async fn complimentary_month(&self, row: &Row, held: Option<i64>) -> Result<ComplimentaryMonth> { |
| 274 | let month = crate::credits::month_of(&rfc3339(now_ms())); |
| 275 | let used = self.allowance_used("complimentary", &row.workspace, &month).await?; |
| 276 | // Metered this month and not on the ledger yet: the allowance pays |
| 277 | // for it when the month closes. |
| 278 | let pending = self |
| 279 | .sum( |
| 280 | &format!( |
| 281 | "SELECT SUM({}) AS micros FROM pending_usage WHERE workspace = ?1 AND month = ?2 AND charged_at IS NULL", |
| 282 | crate::storage::NET_CHARGE |
| 283 | ), |
| 284 | &[row.workspace.as_str().into(), month.as_str().into()], |
| 285 | ) |
| 286 | .await?; |
| 287 | let held = match held { |
| 288 | Some(held) => held, |
| 289 | None => self |
| 290 | .sum( |
| 291 | "SELECT SUM(hold_micros) AS micros FROM reservations WHERE workspace = ?1 AND settled_at IS NULL AND expires_at > ?2", |
| 292 | &[row.workspace.as_str().into(), rfc3339(now_ms()).into()], |
| 293 | ) |
| 294 | .await?, |
| 295 | }; |
| 296 | let overrun = self |
| 297 | .sum( |
| 298 | "SELECT SUM(given_micros) AS micros FROM ledger |
| 299 | WHERE workspace = ?1 AND kind = 'usage' AND created_at >= ?2 AND given_micros > 0 AND EXISTS ( |
| 300 | SELECT 1 FROM complimentary_periods p WHERE p.workspace = ?1 AND p.starts_at <= ledger.created_at |
| 301 | AND (p.ends_at IS NULL OR p.ends_at > ledger.created_at))", |
| 302 | &[row.workspace.as_str().into(), format!("{month}-01").into()], |
| 303 | ) |
| 304 | .await?; |
| 305 | let has_card = row.charges_past() && self.has_card(&row.workspace).await?; |
| 306 | Ok(month_standing(&month, row, used, pending, held, overrun, has_card)) |
| 307 | } |
| 308 | |
| 309 | /// Whether the workspace's own terms are a 100% discount: g1t's own. |
| 310 | async fn internal_terms(&self, workspace: &str) -> Result<bool> { |
| 311 | Ok(self.account_of(workspace).await?.terms.full_discount()) |
| 312 | } |
| 313 | |
| 314 | /// Whether the workspace has a card to charge: one saved by a card |
| 315 | /// check, or the customer's default card as last synced from Stripe. |
| 316 | pub(crate) async fn has_card(&self, workspace: &str) -> Result<bool> { |
| 317 | Ok(self |
| 318 | .db |
| 319 | .prepare( |
| 320 | "SELECT 1 AS found WHERE EXISTS (SELECT 1 FROM card_checks WHERE workspace = ?1) |
| 321 | OR EXISTS (SELECT 1 FROM accounts WHERE workspace = ?1 AND customer_id IS NOT NULL AND card_last4 IS NOT NULL)", |
| 322 | ) |
| 323 | .bind(&[workspace.into()])? |
| 324 | .first::<serde_json::Value>(None) |
| 325 | .await? |
| 326 | .is_some()) |
| 327 | } |
| 328 | |
| 329 | async fn complimentary_standing(&self, row: Row) -> Result<ComplimentaryStanding> { |
| 330 | #[derive(Deserialize)] |
| 331 | struct PeriodRow { |
| 332 | id: String, |
| 333 | starts_at: String, |
| 334 | ends_at: Option<String>, |
| 335 | allowance_micros: i64, |
| 336 | unlimited: i64, |
| 337 | fee_cents: i64, |
| 338 | overage: String, |
| 339 | note: String, |
| 340 | created_by: String, |
| 341 | } |
| 342 | let periods = self |
| 343 | .db |
| 344 | .prepare("SELECT * FROM complimentary_periods WHERE workspace = ? ORDER BY starts_at DESC LIMIT 50") |
| 345 | .bind(&[row.workspace.as_str().into()])? |
| 346 | .all() |
| 347 | .await? |
| 348 | .results::<PeriodRow>()? |
| 349 | .into_iter() |
| 350 | .map(|p| ComplimentaryPeriod { |
| 351 | id: p.id, |
| 352 | starts_at: p.starts_at, |
| 353 | ends_at: p.ends_at, |
| 354 | allowance_micros: p.allowance_micros, |
| 355 | unlimited: p.unlimited != 0, |
| 356 | fee_cents: p.fee_cents.max(0) as u32, |
| 357 | charge_past_allowance: p.overage == "charge", |
| 358 | note: p.note, |
| 359 | created_by: p.created_by, |
| 360 | }) |
| 361 | .collect(); |
| 362 | let w: worker::wasm_bindgen::JsValue = row.workspace.as_str().into(); |
| 363 | // Money in: plan and fee invoices, and top-ups that are payments |
| 364 | // (card prepayments, bank transfers), never credit from g1t. |
| 365 | let plans = self.sum("SELECT SUM(amount_micros) AS micros FROM plan_payments WHERE workspace = ?", &[w.clone()]).await?; |
| 366 | let paid = self |
| 367 | .sum( |
| 368 | "SELECT SUM(amount_micros) AS micros FROM ledger WHERE workspace = ? AND kind = 'top_up' AND credit_kind IS NULL AND reference NOT LIKE 'crd%'", |
| 369 | &[w.clone()], |
| 370 | ) |
| 371 | .await?; |
| 372 | let promotional = self |
| 373 | .sum( |
| 374 | "SELECT SUM(amount_micros) AS micros FROM ledger WHERE workspace = ? AND kind = 'top_up' AND credit_kind IN ('promotional', 'goodwill')", |
| 375 | &[w.clone()], |
| 376 | ) |
| 377 | .await?; |
| 378 | let gifted = self.sum("SELECT SUM(gift_micros) AS micros FROM ledger WHERE workspace = ? AND kind = 'usage'", &[w]).await?; |
| 379 | let month = if row.status == "active" { Some(self.complimentary_month(&row, None).await?) } else { None }; |
| 380 | Ok(ComplimentaryStanding { |
| 381 | workspace: row.workspace.clone(), |
| 382 | grant_id: row.grant_id.clone(), |
| 383 | status: row.status.clone(), |
| 384 | allowance_micros: row.allowance_micros, |
| 385 | unlimited: row.unlimited(), |
| 386 | fee_cents: row.fee_cents.max(0) as u32, |
| 387 | charge_past_allowance: row.charges_past(), |
| 388 | live: row.live != 0, |
| 389 | customer_id: row.customer_id.clone(), |
| 390 | subscription_id: row.subscription_id.clone(), |
| 391 | stripe_status: row.stripe_status.clone(), |
| 392 | pending_op: Op::parse(row.pending_op.as_deref()).map(|op| match op { |
| 393 | Op::Create => "create".to_owned(), |
| 394 | Op::Terms { .. } => "terms".to_owned(), |
| 395 | Op::Cancel => "cancel".to_owned(), |
| 396 | Op::Convert { .. } => "convert".to_owned(), |
| 397 | }), |
| 398 | last_error: row.last_error.clone(), |
| 399 | note: row.note.clone(), |
| 400 | created_by: row.created_by.clone(), |
| 401 | created_at: row.created_at.clone(), |
| 402 | updated_by: row.updated_by.clone(), |
| 403 | updated_at: row.updated_at.clone(), |
| 404 | ended_at: row.ended_at.clone(), |
| 405 | ended_reason: row.ended_reason.clone(), |
| 406 | month, |
| 407 | periods, |
| 408 | money_received_micros: plans + paid, |
| 409 | promotional_credit_micros: promotional, |
| 410 | gifted_micros: gifted, |
| 411 | }) |
| 412 | } |
| 413 | |
| 414 | /// `admin_complimentary`. |
| 415 | pub(crate) async fn admin_complimentary(&self, a: AdminComplimentaryArgs) -> Result<Option<ComplimentaryStanding>> { |
| 416 | match self.complimentary_row(&a.workspace.trim().to_lowercase()).await? { |
| 417 | Some(row) => Ok(Some(self.complimentary_standing(row).await?)), |
| 418 | None => Ok(None), |
| 419 | } |
| 420 | } |
| 421 | |
| 422 | /// `admin_set_complimentary`: enrolls, or changes the terms from now on. |
| 423 | pub(crate) async fn admin_set_complimentary(&self, a: AdminSetComplimentaryArgs) -> Result<Outcome<ComplimentaryStanding>> { |
| 424 | if let Some(why) = invalid(&a) { |
| 425 | return Ok(Outcome::fail(FailureCode::Invalid, why)); |
| 426 | } |
| 427 | let Some(stripe) = &self.stripe else { |
| 428 | return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t: an enrollment needs a Stripe subscription.")); |
| 429 | }; |
| 430 | let workspace = a.workspace.trim().to_lowercase(); |
| 431 | let by = a.by.trim(); |
| 432 | let note = a.note.trim(); |
| 433 | let account = self.account_of(&workspace).await?; |
| 434 | if account.kind == g1t_contracts::billing::AccountKind::Enterprise { |
| 435 | return Ok(Outcome::fail(FailureCode::Conflict, "An enterprise pays for this workspace. Detach it from the enterprise first.")); |
| 436 | } |
| 437 | // Never two billable subscriptions: a paid plan ends (or the owner |
| 438 | // keeps it) before an enrollment starts. |
| 439 | let paying = self |
| 440 | .db |
| 441 | .prepare("SELECT subscription_id FROM subscriptions WHERE workspace = ? AND feature IN ('plan', 'deployments') AND status <> 'canceled' LIMIT 1") |
| 442 | .bind(&[workspace.as_str().into()])? |
| 443 | .first::<serde_json::Value>(None) |
| 444 | .await?; |
| 445 | if paying.is_some() { |
| 446 | return Ok(Outcome::fail( |
| 447 | FailureCode::Conflict, |
| 448 | "The workspace pays for the g1t plan. Cancel that subscription (or let it end) before enrolling it, so it is never billed twice.", |
| 449 | )); |
| 450 | } |
| 451 | // A plan's payment page still open would start a second subscription. |
| 452 | let checkout = self |
| 453 | .db |
| 454 | .prepare("SELECT id FROM checkouts WHERE workspace = ? AND feature IN ('plan', 'deployments') AND status = 'open' AND created_at > ? LIMIT 1") |
| 455 | .bind(&[workspace.as_str().into(), rfc3339(now_ms().saturating_sub(KEY_KEPT_MS + 60 * 60 * 1000)).into()])? |
| 456 | .first::<serde_json::Value>(None) |
| 457 | .await?; |
| 458 | if checkout.is_some() { |
| 459 | return Ok(Outcome::fail( |
| 460 | FailureCode::Conflict, |
| 461 | "An owner has a payment page open for the g1t plan. Wait for it to finish or expire (a day) before enrolling the workspace.", |
| 462 | )); |
| 463 | } |
| 464 | let now_ms = now_ms(); |
| 465 | let now = rfc3339(now_ms); |
| 466 | let unlimited = i32::from(a.unlimited); |
| 467 | let fee = f64::from(a.fee_cents); |
| 468 | let overage = if a.charge_past_allowance { "charge" } else { "stop" }; |
| 469 | let terms = format!( |
| 470 | "{} a month, fee {}, past it {}", |
| 471 | if a.unlimited { "unlimited allowance".to_owned() } else { format!("{} allowance", cents(a.allowance_micros)) }, |
| 472 | if a.fee_cents == 0 { "waived".to_owned() } else { cents(i64::from(a.fee_cents) * 10_000) }, |
| 473 | if a.charge_past_allowance { "charged to their card" } else { "work stops" } |
| 474 | ); |
| 475 | let existing = self.complimentary_row(&workspace).await?; |
| 476 | match existing.as_ref().map(|row| row.status.as_str()) { |
| 477 | None | Some("ended") => { |
| 478 | // A canceled enrollment whose cancel is still owed at Stripe |
| 479 | // would be forgotten, still billing: finish it first. |
| 480 | if existing.as_ref().is_some_and(|row| row.pending_op.is_some()) { |
| 481 | return Ok(Outcome::fail( |
| 482 | FailureCode::Conflict, |
| 483 | "The last enrollment's subscription is still being canceled at Stripe; try again once it is (it is retried every fifteen minutes).", |
| 484 | )); |
| 485 | } |
| 486 | let grant = new_id("cmp", now_ms); |
| 487 | self.db |
| 488 | .prepare( |
| 489 | "INSERT INTO complimentary (workspace, grant_id, status, allowance_micros, unlimited, fee_cents, live, pending_op, note, |
| 490 | created_by, created_at, created_ms, updated_by, updated_at, overage) |
| 491 | VALUES (?1, ?2, 'pending', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?9, ?10, ?12) |
| 492 | ON CONFLICT (workspace) DO UPDATE SET grant_id = ?2, status = 'pending', allowance_micros = ?3, unlimited = ?4, |
| 493 | fee_cents = ?5, pending_op = ?7, note = ?8, created_by = ?9, created_at = ?10, created_ms = ?11, |
| 494 | updated_by = ?9, updated_at = ?10, overage = ?12, subscription_id = NULL, item_id = NULL, price_id = NULL, |
| 495 | stripe_status = NULL, last_error = NULL, ended_at = NULL, ended_reason = NULL, converted_at = NULL, |
| 496 | -- A customer of the other Stripe mode is never reused. |
| 497 | customer_id = CASE WHEN complimentary.live = ?6 THEN complimentary.customer_id END, live = ?6 |
| 498 | WHERE complimentary.status = 'ended' AND complimentary.pending_op IS NULL", |
| 499 | ) |
| 500 | .bind(&[ |
| 501 | workspace.as_str().into(), |
| 502 | grant.as_str().into(), |
| 503 | (a.allowance_micros as f64).into(), |
| 504 | unlimited.into(), |
| 505 | fee.into(), |
| 506 | i32::from(stripe.live()).into(), |
| 507 | Op::Create.text().into(), |
| 508 | note.into(), |
| 509 | by.into(), |
| 510 | now.as_str().into(), |
| 511 | (now_ms as f64).into(), |
| 512 | overage.into(), |
| 513 | ])? |
| 514 | .run() |
| 515 | .await?; |
| 516 | self.audit(&account.id, "complimentary", &format!("{workspace} enrolled ({grant}): {terms}: {note}"), by).await?; |
| 517 | } |
| 518 | Some("pending") => { |
| 519 | // Not visible yet: its first period takes these terms. |
| 520 | self.db |
| 521 | .prepare( |
| 522 | "UPDATE complimentary SET allowance_micros = ?2, unlimited = ?3, fee_cents = ?4, note = ?5, updated_by = ?6, updated_at = ?7, |
| 523 | overage = ?8 |
| 524 | WHERE workspace = ?1 AND status = 'pending'", |
| 525 | ) |
| 526 | .bind(&[ |
| 527 | workspace.as_str().into(), |
| 528 | (a.allowance_micros as f64).into(), |
| 529 | unlimited.into(), |
| 530 | fee.into(), |
| 531 | note.into(), |
| 532 | by.into(), |
| 533 | now.as_str().into(), |
| 534 | overage.into(), |
| 535 | ])? |
| 536 | .run() |
| 537 | .await?; |
| 538 | self.audit(&account.id, "complimentary", &format!("{workspace} pending enrollment changed: {terms}: {note}"), by).await?; |
| 539 | } |
| 540 | Some(_) => { |
| 541 | let row = existing.as_ref().expect("matched an existing row"); |
| 542 | if matches!(Op::parse(row.pending_op.as_deref()), Some(Op::Convert { .. } | Op::Create)) { |
| 543 | return Ok(Outcome::fail(FailureCode::Conflict, "The owner is converting this workspace to the plan; wait for that to finish.")); |
| 544 | } |
| 545 | // One batch: the open period closes and the next opens with |
| 546 | // the new terms. What this month used stays used. |
| 547 | let period = new_id("cmpp", now_ms); |
| 548 | self.db |
| 549 | .batch(vec![ |
| 550 | self.db |
| 551 | .prepare("UPDATE complimentary_periods SET ends_at = ?2 WHERE workspace = ?1 AND ends_at IS NULL") |
| 552 | .bind(&[workspace.as_str().into(), now.as_str().into()])?, |
| 553 | self.db |
| 554 | .prepare( |
| 555 | "INSERT INTO complimentary_periods (id, workspace, grant_id, starts_at, allowance_micros, unlimited, fee_cents, note, created_by, created_at, overage) |
| 556 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?4, ?10)", |
| 557 | ) |
| 558 | .bind(&[ |
| 559 | period.as_str().into(), |
| 560 | workspace.as_str().into(), |
| 561 | row.grant_id.as_str().into(), |
| 562 | now.as_str().into(), |
| 563 | (a.allowance_micros as f64).into(), |
| 564 | unlimited.into(), |
| 565 | fee.into(), |
| 566 | note.into(), |
| 567 | by.into(), |
| 568 | overage.into(), |
| 569 | ])?, |
| 570 | self.db |
| 571 | .prepare( |
| 572 | "UPDATE complimentary SET allowance_micros = ?2, unlimited = ?3, fee_cents = ?4, note = ?5, updated_by = ?6, updated_at = ?7, |
| 573 | pending_op = ?8, overage = ?9 WHERE workspace = ?1 AND status = 'active'", |
| 574 | ) |
| 575 | .bind(&[ |
| 576 | workspace.as_str().into(), |
| 577 | (a.allowance_micros as f64).into(), |
| 578 | unlimited.into(), |
| 579 | fee.into(), |
| 580 | note.into(), |
| 581 | by.into(), |
| 582 | now.as_str().into(), |
| 583 | Op::Terms { period: period.clone() }.text().into(), |
| 584 | overage.into(), |
| 585 | ])?, |
| 586 | ]) |
| 587 | .await?; |
| 588 | self.audit( |
| 589 | &account.id, |
| 590 | "complimentary", |
| 591 | &format!( |
| 592 | "{workspace} terms changed from {} allowance, fee {} to {terms}: {note}", |
| 593 | if row.unlimited() { "unlimited".to_owned() } else { cents(row.allowance_micros) }, |
| 594 | cents(row.fee_cents * 10_000) |
| 595 | ), |
| 596 | by, |
| 597 | ) |
| 598 | .await?; |
| 599 | } |
| 600 | } |
| 601 | self.drive_logged(&workspace).await; |
| 602 | match self.complimentary_row(&workspace).await? { |
| 603 | Some(row) => Ok(Outcome::Ok(self.complimentary_standing(row).await?)), |
| 604 | None => Ok(Outcome::fail(FailureCode::NotFound, "The enrollment was not saved.")), |
| 605 | } |
| 606 | } |
| 607 | |
| 608 | /// `admin_end_complimentary`: the workspace goes back to Free now. |
| 609 | pub(crate) async fn admin_end_complimentary(&self, a: AdminEndComplimentaryArgs) -> Result<Outcome<ComplimentaryStanding>> { |
| 610 | let workspace = a.workspace.trim().to_lowercase(); |
| 611 | if a.note.trim().len() < 3 || a.by.trim().is_empty() { |
| 612 | return Ok(Outcome::fail(FailureCode::Invalid, "Say why, in a few words: it goes in the audit log.")); |
| 613 | } |
| 614 | let Some(row) = self.complimentary_row(&workspace).await?.filter(|row| row.status != "ended") else { |
| 615 | return Ok(Outcome::fail(FailureCode::NotFound, "The workspace has no enrollment to end.")); |
| 616 | }; |
| 617 | if matches!(Op::parse(row.pending_op.as_deref()), Some(Op::Convert { .. })) { |
| 618 | return Ok(Outcome::fail(FailureCode::Conflict, "The owner is converting this workspace to the plan; wait for that to finish.")); |
| 619 | } |
| 620 | self.end(&row, "revoked", a.by.trim(), a.note.trim()).await?; |
| 621 | self.drive_logged(&workspace).await; |
| 622 | match self.complimentary_row(&workspace).await? { |
| 623 | Some(row) => Ok(Outcome::Ok(self.complimentary_standing(row).await?)), |
| 624 | None => Ok(Outcome::fail(FailureCode::NotFound, "The enrollment is gone.")), |
| 625 | } |
| 626 | } |
| 627 | |
| 628 | /// Ends an enrollment: month-end usage so far is put on the ledger |
| 629 | /// under the allowance, the open period closes, and the subscription is |
| 630 | /// canceled (revoked) or left to the plan (converted). |
| 631 | async fn end(&self, row: &Row, reason: &str, by: &str, note: &str) -> Result<()> { |
| 632 | let now_ms = now_ms(); |
| 633 | let now = rfc3339(now_ms); |
| 634 | if row.status == "active" { |
| 635 | self.post_pending_under_allowance(row, now_ms).await?; |
| 636 | } |
| 637 | let op = if reason == "revoked" && (row.subscription_id.is_some() || row.status == "pending") { Some(Op::Cancel.text()) } else { None }; |
| 638 | self.db |
| 639 | .batch(vec![ |
| 640 | self.db |
| 641 | .prepare("UPDATE complimentary_periods SET ends_at = ?2 WHERE workspace = ?1 AND ends_at IS NULL") |
| 642 | .bind(&[row.workspace.as_str().into(), now.as_str().into()])?, |
| 643 | self.db |
| 644 | .prepare( |
| 645 | "UPDATE complimentary SET status = 'ended', ended_at = ?2, ended_reason = ?3, pending_op = ?4, updated_by = ?5, updated_at = ?2, |
| 646 | converted_at = CASE WHEN ?3 = 'converted' THEN ?2 END |
| 647 | WHERE workspace = ?1 AND grant_id = ?6 AND status <> 'ended'", |
| 648 | ) |
| 649 | .bind(&[ |
| 650 | row.workspace.as_str().into(), |
| 651 | now.as_str().into(), |
| 652 | reason.into(), |
| 653 | crate::optional(op.as_deref()), |
| 654 | by.into(), |
| 655 | row.grant_id.as_str().into(), |
| 656 | ])?, |
| 657 | ]) |
| 658 | .await?; |
| 659 | let account = self.account_of(&row.workspace).await?; |
| 660 | self.audit(&account.id, "complimentary", &format!("{} enrollment {} ended ({reason}): {note}", row.workspace, row.grant_id), by).await |
| 661 | } |
| 662 | |
| 663 | /// Month-end meters (storage, git operations…) run up so far this month |
| 664 | /// go on the ledger now, timed just before the period closes, so the |
| 665 | /// allowance pays for them as it would have at the close. The month's |
| 666 | /// later charge takes only what is metered after (`pending_baselines`). |
| 667 | /// Never charges usage gifted while enrolled. |
| 668 | async fn post_pending_under_allowance(&self, row: &Row, now_ms: u64) -> Result<()> { |
| 669 | #[derive(Deserialize)] |
| 670 | struct Pending { |
| 671 | source: String, |
| 672 | month: String, |
| 673 | cost_micros: Option<i64>, |
| 674 | raw_cost: Option<i64>, |
| 675 | raw_charge: Option<i64>, |
| 676 | } |
| 677 | let at = rfc3339(now_ms.saturating_sub(1)); |
| 678 | let month = crate::credits::month_of(&at); |
| 679 | let marks = crate::storage::CHARGED_HERE.iter().map(|s| format!("'{s}'")).collect::<Vec<_>>().join(", "); |
| 680 | let rows = self |
| 681 | .db |
| 682 | .prepare(format!( |
| 683 | "SELECT source, month, {} AS cost_micros, cost_micros AS raw_cost, charge_micros AS raw_charge FROM pending_usage |
| 684 | WHERE workspace = ?1 AND month = ?2 AND charged_at IS NULL AND source IN ({marks})", |
| 685 | crate::storage::NET_COST |
| 686 | )) |
| 687 | .bind(&[row.workspace.as_str().into(), month.as_str().into()])? |
| 688 | .all() |
| 689 | .await? |
| 690 | .results::<Pending>()?; |
| 691 | for pending in rows { |
| 692 | let cost = pending.cost_micros.unwrap_or(0); |
| 693 | // The baseline moves to exactly the figure posted here, read |
| 694 | // above: a report landing meanwhile stays past it, charged at the |
| 695 | // month's close. An empty row still marks where the next starts. |
| 696 | let baseline = self |
| 697 | .db |
| 698 | .prepare( |
| 699 | "INSERT INTO pending_baselines (workspace, source, month, cost_micros, charge_micros, created_at) |
| 700 | VALUES (?1, ?2, ?3, ?5, ?6, ?4) |
| 701 | ON CONFLICT (workspace, source, month) DO UPDATE SET |
| 702 | cost_micros = MAX(pending_baselines.cost_micros, excluded.cost_micros), |
| 703 | charge_micros = MAX(pending_baselines.charge_micros, excluded.charge_micros), created_at = excluded.created_at", |
| 704 | ) |
| 705 | .bind(&[ |
| 706 | row.workspace.as_str().into(), |
| 707 | pending.source.as_str().into(), |
| 708 | pending.month.as_str().into(), |
| 709 | at.as_str().into(), |
| 710 | (pending.raw_cost.unwrap_or(0) as f64).into(), |
| 711 | (pending.raw_charge.unwrap_or(0) as f64).into(), |
| 712 | ])?; |
| 713 | if cost <= 0 { |
| 714 | baseline.run().await?; |
| 715 | continue; |
| 716 | } |
| 717 | let base = crate::credits::with_margin(cost, self.margin_percent); |
| 718 | let (charge, terms_note, discount) = self.charged(&row.workspace, base).await?; |
| 719 | let description = format!( |
| 720 | "{} in {} while enrolled{terms_note}", |
| 721 | crate::storage::title(&pending.source), |
| 722 | pending.month |
| 723 | ); |
| 724 | let reference = format!("{}/{}/{}/{}", pending.source, row.workspace, pending.month, row.grant_id); |
| 725 | let eligible = crate::credits::Eligible::default(); |
| 726 | let line = crate::storage::UsageLine { |
| 727 | workspace: &row.workspace, |
| 728 | charged: charge, |
| 729 | description: &description, |
| 730 | repo: None, |
| 731 | task: &pending.source, |
| 732 | cost, |
| 733 | reference: &reference, |
| 734 | created_at: &at, |
| 735 | drawn: crate::credits::Drawn::default(), |
| 736 | }; |
| 737 | if self.post_usage_with(line, vec![baseline], Some((&pending.month, &eligible, false))).await? { |
| 738 | self.record_discount(&reference, discount).await?; |
| 739 | } |
| 740 | } |
| 741 | Ok(()) |
| 742 | } |
| 743 | |
| 744 | /// Runs the workspace's owed Stripe step, writing down an error for sudo |
| 745 | /// rather than failing the change staff made. |
| 746 | async fn drive_logged(&self, workspace: &str) { |
| 747 | if let Err(error) = self.drive(workspace).await { |
| 748 | worker::console_error!("{workspace}: complimentary Stripe step failed; tried again next tick: {error}"); |
| 749 | let noted = match self.db.prepare("UPDATE complimentary SET last_error = ?2 WHERE workspace = ?1") |
| 750 | .bind(&[workspace.into(), crate::stripe::friendly(&error).into()]) { |
| 751 | Ok(statement) => statement.run().await.map(|_| ()), |
| 752 | Err(error) => Err(error), |
| 753 | }; |
| 754 | if let Err(error) = noted { |
| 755 | worker::console_error!("{workspace}: could not note the error: {error}"); |
| 756 | } |
| 757 | } |
| 758 | } |
| 759 | |
| 760 | /// The fifteen-minute tick: every enrollment with a Stripe step owed. |
| 761 | pub(crate) async fn complimentary_tick(&self) -> Result<()> { |
| 762 | #[derive(Deserialize)] |
| 763 | struct Owed { |
| 764 | workspace: String, |
| 765 | } |
| 766 | let owed = self |
| 767 | .db |
| 768 | .prepare("SELECT workspace FROM complimentary WHERE pending_op IS NOT NULL ORDER BY updated_at LIMIT ?") |
| 769 | .bind(&[TICK_BATCH.into()])? |
| 770 | .all() |
| 771 | .await? |
| 772 | .results::<Owed>()?; |
| 773 | for row in owed { |
| 774 | if let Err(error) = self.drive(&row.workspace).await { |
| 775 | worker::console_error!("{}: complimentary Stripe step failed: {error}", row.workspace); |
| 776 | self.db |
| 777 | .prepare("UPDATE complimentary SET last_error = ?2 WHERE workspace = ?1") |
| 778 | .bind(&[row.workspace.as_str().into(), crate::stripe::friendly(&error).into()])? |
| 779 | .run() |
| 780 | .await?; |
| 781 | } |
| 782 | } |
| 783 | Ok(()) |
| 784 | } |
| 785 | |
| 786 | /// Does the owed Stripe step, if any. |
| 787 | async fn drive(&self, workspace: &str) -> Result<()> { |
| 788 | let Some(stripe) = &self.stripe else { return Ok(()) }; |
| 789 | let Some(row) = self.complimentary_row(workspace).await? else { return Ok(()) }; |
| 790 | let Some(op) = Op::parse(row.pending_op.as_deref()) else { return Ok(()) }; |
| 791 | // Test objects are never carried into live mode, nor the reverse. |
| 792 | if (row.live != 0) != stripe.live() && (row.subscription_id.is_some() || row.customer_id.is_some()) { |
| 793 | return Err(worker::Error::RustError(format!( |
| 794 | "this enrollment's Stripe objects are {}-mode and billing now uses a {}-mode key: end it and enroll again", |
| 795 | if row.live != 0 { "live" } else { "test" }, |
| 796 | if stripe.live() { "live" } else { "test" } |
| 797 | ))); |
| 798 | } |
| 799 | match op { |
| 800 | Op::Create => self.drive_create(stripe, &row).await, |
| 801 | Op::Terms { period } => self.drive_terms(stripe, &row, &period).await, |
| 802 | Op::Cancel => self.drive_cancel(stripe, &row).await, |
| 803 | // A conversion moves on when its payment does: the webhook or |
| 804 | // the owner's next look settles it (`settle_complimentary`). |
| 805 | Op::Convert { started_ms, .. } => { |
| 806 | if let Some(id) = &row.subscription_id { |
| 807 | self.settle_complimentary(id).await?; |
| 808 | } |
| 809 | // A conversion Stripe gave up on (its pending update expires |
| 810 | // after 23 hours) stops waiting, and the enrollment stands. |
| 811 | let fresh = self.complimentary_row(workspace).await?; |
| 812 | if now_ms().saturating_sub(started_ms) > 2 * KEY_KEPT_MS && fresh.as_ref().is_some_and(|r| r.status == "active") { |
| 813 | // Preparing it charged the subscription automatically; a |
| 814 | // fee goes back to being invoiced. |
| 815 | if let (true, Some(id)) = (row.fee_cents > 0, &row.subscription_id) { |
| 816 | let _: serde_json::Value = stripe |
| 817 | .post_idempotent( |
| 818 | &format!("/subscriptions/{id}"), |
| 819 | &[("collection_method", "send_invoice".to_owned()), ("days_until_due", "30".to_owned())], |
| 820 | &format!("cmp/{}/convert/{started_ms}/undo", row.grant_id), |
| 821 | ) |
| 822 | .await?; |
| 823 | } |
| 824 | self.db |
| 825 | .prepare("UPDATE complimentary SET pending_op = NULL WHERE workspace = ?1 AND pending_op = ?2 AND status = 'active'") |
| 826 | .bind(&[row.workspace.as_str().into(), row.pending_op.clone().unwrap_or_default().into()])? |
| 827 | .run() |
| 828 | .await?; |
| 829 | } |
| 830 | Ok(()) |
| 831 | } |
| 832 | } |
| 833 | } |
| 834 | |
| 835 | /// The customer for an enrollment: the workspace's own, or one made |
| 836 | /// under the grant's key. |
| 837 | async fn enrollment_customer(&self, stripe: &Stripe, row: &Row) -> Result<String> { |
| 838 | if let Some(customer) = &row.customer_id { |
| 839 | return Ok(customer.clone()); |
| 840 | } |
| 841 | let existing = self.row(&row.workspace).await?.and_then(|account| account.customer_id); |
| 842 | let customer = match existing { |
| 843 | Some(customer) => customer, |
| 844 | None => { |
| 845 | let old = now_ms().saturating_sub(row.created_ms.max(0) as u64) > KEY_KEPT_MS; |
| 846 | let found = if old { |
| 847 | let query = crate::stripe::form(&[("query", format!("metadata['g1t_grant']:'{}'", row.grant_id))]); |
| 848 | let list: serde_json::Value = stripe.get(&format!("/customers/search?{query}")).await?; |
| 849 | list["data"].as_array().and_then(|d| d.first()).and_then(|c| c["id"].as_str()).map(str::to_owned) |
| 850 | } else { |
| 851 | None |
| 852 | }; |
| 853 | match found { |
| 854 | Some(customer) => customer, |
| 855 | // Search sees a customer within about a minute; a day |
| 856 | // later, none found means none was made. |
| 857 | None => { |
| 858 | let email = self.owner_email(&row.workspace).await; |
| 859 | let mut fields = crate::stripe::customer_fields(&row.workspace, email.as_deref()); |
| 860 | fields.push(("metadata[g1t_grant]", row.grant_id.clone())); |
| 861 | #[derive(Deserialize)] |
| 862 | struct Customer { |
| 863 | id: String, |
| 864 | } |
| 865 | let key = if old { format!("cmp/{}/customer/late", row.grant_id) } else { format!("cmp/{}/customer", row.grant_id) }; |
| 866 | let made: Customer = stripe.post_idempotent("/customers", &fields, &key).await?; |
| 867 | made.id |
| 868 | } |
| 869 | } |
| 870 | } |
| 871 | }; |
| 872 | self.db |
| 873 | .batch(vec![ |
| 874 | self.db |
| 875 | .prepare("UPDATE complimentary SET customer_id = ?2 WHERE workspace = ?1 AND grant_id = ?3") |
| 876 | .bind(&[row.workspace.as_str().into(), customer.as_str().into(), row.grant_id.as_str().into()])?, |
| 877 | self.db |
| 878 | .prepare( |
| 879 | "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at) VALUES (?1, 0, ?2, ?3) |
| 880 | ON CONFLICT (workspace) DO UPDATE SET customer_id = COALESCE(customer_id, ?2)", |
| 881 | ) |
| 882 | .bind(&[row.workspace.as_str().into(), customer.as_str().into(), rfc3339(now_ms()).into()])?, |
| 883 | ]) |
| 884 | .await?; |
| 885 | Ok(customer) |
| 886 | } |
| 887 | |
| 888 | /// The 100% coupon, made once under its own id. |
| 889 | async fn ensure_coupon(&self, stripe: &Stripe) -> Result<()> { |
| 890 | match stripe.get::<serde_json::Value>(&format!("/coupons/{FULL_DISCOUNT_COUPON}")).await { |
| 891 | Ok(_) => Ok(()), |
| 892 | Err(error) if is_missing(&error) => { |
| 893 | let _: serde_json::Value = stripe |
| 894 | .post_idempotent( |
| 895 | "/coupons", |
| 896 | &[ |
| 897 | ("id", FULL_DISCOUNT_COUPON.to_owned()), |
| 898 | ("percent_off", "100".to_owned()), |
| 899 | ("duration", "forever".to_owned()), |
| 900 | ("name", "g1t complimentary (100%)".to_owned()), |
| 901 | ], |
| 902 | &format!("coupon/{FULL_DISCOUNT_COUPON}"), |
| 903 | ) |
| 904 | .await?; |
| 905 | Ok(()) |
| 906 | } |
| 907 | Err(error) => Err(error), |
| 908 | } |
| 909 | } |
| 910 | |
| 911 | /// A subscription Stripe already has for the grant, found by its |
| 912 | /// metadata: what an ambiguous creation made. |
| 913 | async fn grant_subscription(&self, stripe: &Stripe, customer: &str, grant: &str) -> Result<Option<StripeSubscription>> { |
| 914 | #[derive(Deserialize)] |
| 915 | struct List { |
| 916 | data: Vec<StripeSubscription>, |
| 917 | } |
| 918 | let query = crate::stripe::form(&[("customer", customer.to_owned()), ("status", "all".to_owned()), ("limit", "100".to_owned())]); |
| 919 | let list: List = stripe.get(&format!("/subscriptions?{query}")).await?; |
| 920 | Ok(list |
| 921 | .data |
| 922 | .into_iter() |
| 923 | .find(|s| s.metadata.as_ref().and_then(|m| m.get("g1t_grant")).map(String::as_str) == Some(grant) && s.status != "canceled")) |
| 924 | } |
| 925 | |
| 926 | async fn drive_create(&self, stripe: &Stripe, row: &Row) -> Result<()> { |
| 927 | if row.status != "pending" { |
| 928 | return self.clear_op(row, &Op::Create).await; |
| 929 | } |
| 930 | let customer = self.enrollment_customer(stripe, row).await?; |
| 931 | let old = now_ms().saturating_sub(row.created_ms.max(0) as u64) > KEY_KEPT_MS; |
| 932 | let found = if old { self.grant_subscription(stripe, &customer, &row.grant_id).await? } else { None }; |
| 933 | let subscription = match found { |
| 934 | Some(subscription) => subscription, |
| 935 | // Older than the key and nothing found: making it now is safe, |
| 936 | // as the search above saw none. |
| 937 | None => { |
| 938 | let fully = fully_discounted(row, self.internal_terms(&row.workspace).await?); |
| 939 | if fully { |
| 940 | self.ensure_coupon(stripe).await?; |
| 941 | } |
| 942 | let product = stripe.product(Feature::Plan.as_str(), &format!("{} plan", Feature::Plan.title())).await?; |
| 943 | let fields = create_fields(row, &customer, &product, self.plans.plan_monthly_cents, fully); |
| 944 | let key = if old { format!("cmp/{}/subscription/late", row.grant_id) } else { format!("cmp/{}/subscription", row.grant_id) }; |
| 945 | stripe.post_idempotent("/subscriptions", &owned(&fields), &key).await? |
| 946 | } |
| 947 | }; |
| 948 | let item = subscription.first_item(); |
| 949 | let item_id = item.and_then(|i| i.id.clone()); |
| 950 | let price_id = item.and_then(|i| i.price.as_ref()).map(|p| p.id.clone()); |
| 951 | let now = rfc3339(now_ms()); |
| 952 | let period = new_id("cmpp", now_ms()); |
| 953 | // Stripe has it: the workspace sees the enrollment from now, with |
| 954 | // its first period, in one batch. |
| 955 | self.db |
| 956 | .batch(vec![ |
| 957 | self.db |
| 958 | .prepare( |
| 959 | "UPDATE complimentary SET status = 'active', subscription_id = ?2, item_id = ?3, price_id = ?4, stripe_status = ?5, |
| 960 | pending_op = NULL, last_error = NULL, updated_at = ?6 |
| 961 | WHERE workspace = ?1 AND grant_id = ?7 AND status = 'pending'", |
| 962 | ) |
| 963 | .bind(&[ |
| 964 | row.workspace.as_str().into(), |
| 965 | subscription.id.as_str().into(), |
| 966 | crate::optional(item_id.as_deref()), |
| 967 | crate::optional(price_id.as_deref()), |
| 968 | subscription.status.as_str().into(), |
| 969 | now.as_str().into(), |
| 970 | row.grant_id.as_str().into(), |
| 971 | ])?, |
| 972 | self.db |
| 973 | .prepare( |
| 974 | "INSERT INTO complimentary_periods (id, workspace, grant_id, starts_at, allowance_micros, unlimited, fee_cents, note, created_by, created_at, overage) |
| 975 | SELECT ?1, workspace, grant_id, ?2, allowance_micros, unlimited, fee_cents, note, updated_by, ?2, overage |
| 976 | FROM complimentary WHERE workspace = ?3 AND grant_id = ?4 AND status = 'active' AND subscription_id = ?5 |
| 977 | AND NOT EXISTS (SELECT 1 FROM complimentary_periods WHERE workspace = ?3 AND ends_at IS NULL)", |
| 978 | ) |
| 979 | .bind(&[period.as_str().into(), now.as_str().into(), row.workspace.as_str().into(), row.grant_id.as_str().into(), subscription.id.as_str().into()])?, |
| 980 | ]) |
| 981 | .await?; |
| 982 | Ok(()) |
| 983 | } |
| 984 | |
| 985 | async fn drive_terms(&self, stripe: &Stripe, row: &Row, period: &str) -> Result<()> { |
| 986 | let (Some(subscription), Some(item)) = (&row.subscription_id, &row.item_id) else { |
| 987 | return self.clear_op(row, &Op::Terms { period: period.to_owned() }).await; |
| 988 | }; |
| 989 | let fully = fully_discounted(row, self.internal_terms(&row.workspace).await?); |
| 990 | if fully { |
| 991 | self.ensure_coupon(stripe).await?; |
| 992 | } |
| 993 | let product = stripe.product(Feature::Plan.as_str(), &format!("{} plan", Feature::Plan.title())).await?; |
| 994 | let mut fields: Vec<(String, String)> = vec![("items[0][id]".into(), item.clone()), ("proration_behavior".into(), "none".into())]; |
| 995 | fields.extend(price_fields(row, &product, self.plans.plan_monthly_cents, "items[0]", fully)); |
| 996 | fields.extend(metadata_fields(row).into_iter().map(|(k, v)| (k.to_owned(), v))); |
| 997 | if fully { |
| 998 | fields.push(("discounts[0][coupon]".into(), FULL_DISCOUNT_COUPON.to_owned())); |
| 999 | } else { |
| 1000 | fields.push(("discounts".into(), String::new())); |
| 1001 | } |
| 1002 | // A fee is invoiced; with none there is nothing to collect, and |
| 1003 | // converting needs automatic collection. |
| 1004 | if row.fee_cents > 0 { |
| 1005 | fields.push(("collection_method".into(), "send_invoice".into())); |
| 1006 | fields.push(("days_until_due".into(), "30".into())); |
| 1007 | } else { |
| 1008 | fields.push(("collection_method".into(), "charge_automatically".into())); |
| 1009 | } |
| 1010 | let updated: StripeSubscription = stripe |
| 1011 | .post_idempotent(&format!("/subscriptions/{subscription}"), &owned(&fields), &format!("cmp/{}/terms/{period}", row.grant_id)) |
| 1012 | .await?; |
| 1013 | let price_id = updated.first_item().and_then(|i| i.price.as_ref()).map(|p| p.id.clone()); |
| 1014 | self.db |
| 1015 | .prepare( |
| 1016 | "UPDATE complimentary SET price_id = ?2, stripe_status = ?3, pending_op = NULL, last_error = NULL |
| 1017 | WHERE workspace = ?1 AND pending_op = ?4", |
| 1018 | ) |
| 1019 | .bind(&[ |
| 1020 | row.workspace.as_str().into(), |
| 1021 | crate::optional(price_id.as_deref()), |
| 1022 | updated.status.as_str().into(), |
| 1023 | Op::Terms { period: period.to_owned() }.text().into(), |
| 1024 | ])? |
| 1025 | .run() |
| 1026 | .await?; |
| 1027 | Ok(()) |
| 1028 | } |
| 1029 | |
| 1030 | async fn drive_cancel(&self, stripe: &Stripe, row: &Row) -> Result<()> { |
| 1031 | // A creation that may have happened is looked for first. |
| 1032 | let subscription = match (&row.subscription_id, &row.customer_id) { |
| 1033 | (Some(id), _) => Some(id.clone()), |
| 1034 | (None, Some(customer)) => self.grant_subscription(stripe, customer, &row.grant_id).await?.map(|s| s.id), |
| 1035 | (None, None) => None, |
| 1036 | }; |
| 1037 | if let Some(id) = &subscription { |
| 1038 | match stripe.cancel_now(id).await { |
| 1039 | Ok(_) => {} |
| 1040 | Err(error) if is_missing(&error) || error.to_string().contains("canceled subscription") => {} |
| 1041 | Err(error) => return Err(error), |
| 1042 | } |
| 1043 | } else if row.status == "pending" && now_ms().saturating_sub(row.created_ms.max(0) as u64) <= KEY_KEPT_MS { |
| 1044 | // A creation may still land under its key: make it now so it can |
| 1045 | // be canceled, rather than leave one behind. |
| 1046 | if row.customer_id.is_some() { |
| 1047 | return Err(worker::Error::RustError("waiting for Stripe to show the enrollment's subscription before canceling it".into())); |
| 1048 | } |
| 1049 | } |
| 1050 | self.db |
| 1051 | .prepare("UPDATE complimentary SET stripe_status = 'canceled', pending_op = NULL, last_error = NULL WHERE workspace = ?1 AND pending_op = ?2") |
| 1052 | .bind(&[row.workspace.as_str().into(), Op::Cancel.text().into()])? |
| 1053 | .run() |
| 1054 | .await?; |
| 1055 | Ok(()) |
| 1056 | } |
| 1057 | |
| 1058 | async fn clear_op(&self, row: &Row, op: &Op) -> Result<()> { |
| 1059 | self.db |
| 1060 | .prepare("UPDATE complimentary SET pending_op = NULL WHERE workspace = ?1 AND pending_op = ?2") |
| 1061 | .bind(&[row.workspace.as_str().into(), op.text().into()])? |
| 1062 | .run() |
| 1063 | .await?; |
| 1064 | Ok(()) |
| 1065 | } |
| 1066 | |
| 1067 | /// A Stripe change to an enrollment's subscription: renewed, paid, |
| 1068 | /// canceled at Stripe, or converted to the plan. None when the |
| 1069 | /// subscription is no enrollment's. |
| 1070 | pub(crate) async fn settle_complimentary(&self, subscription_id: &str) -> Result<Option<String>> { |
| 1071 | let Some(row) = self |
| 1072 | .db |
| 1073 | .prepare("SELECT * FROM complimentary WHERE subscription_id = ?") |
| 1074 | .bind(&[subscription_id.into()])? |
| 1075 | .first::<Row>(None) |
| 1076 | .await? |
| 1077 | else { |
| 1078 | return Ok(None); |
| 1079 | }; |
| 1080 | let Some(stripe) = &self.stripe else { return Ok(Some("ignored: payments off".into())) }; |
| 1081 | let subscription = match stripe.subscription(subscription_id).await { |
| 1082 | Ok(subscription) => subscription, |
| 1083 | Err(error) if is_missing(&error) => { |
| 1084 | if row.status == "active" { |
| 1085 | self.end(&row, "revoked", "stripe", "its subscription is gone at Stripe").await?; |
| 1086 | } |
| 1087 | return Ok(Some(format!("{}: enrollment's subscription is gone at Stripe", row.workspace))); |
| 1088 | } |
| 1089 | Err(error) => return Err(error), |
| 1090 | }; |
| 1091 | // Converting: an owner's attempt in progress, or (with no staff change |
| 1092 | // in flight) the plan's price on an enrollment whose own price |
| 1093 | // differs from it, which only a conversion puts there. |
| 1094 | let converting = match Op::parse(row.pending_op.as_deref()) { |
| 1095 | Some(Op::Convert { .. }) => true, |
| 1096 | None => row.fee_cents != i64::from(self.plans.plan_monthly_cents) && !self.internal_terms(&row.workspace).await?, |
| 1097 | _ => false, |
| 1098 | }; |
| 1099 | if row.status == "active" && converted(row.price_id.as_deref(), &subscription, self.plans.plan_monthly_cents, converting) { |
| 1100 | let by = match Op::parse(row.pending_op.as_deref()) { |
| 1101 | Some(Op::Convert { by, .. }) => by, |
| 1102 | _ => "stripe".to_owned(), |
| 1103 | }; |
| 1104 | self.finish_conversion(&row, &subscription, &by).await?; |
| 1105 | return Ok(Some(format!("{} converted to the plan", row.workspace))); |
| 1106 | } |
| 1107 | self.db |
| 1108 | .prepare("UPDATE complimentary SET stripe_status = ?2 WHERE workspace = ?1") |
| 1109 | .bind(&[row.workspace.as_str().into(), subscription.status.as_str().into()])? |
| 1110 | .run() |
| 1111 | .await?; |
| 1112 | // Canceled (or unpaid) at Stripe: the enrollment ends with it. |
| 1113 | if row.status == "active" && matches!(subscription.status.as_str(), "canceled" | "incomplete_expired" | "unpaid") { |
| 1114 | self.end(&row, "revoked", "stripe", &format!("its subscription is {} at Stripe", subscription.status)).await?; |
| 1115 | return Ok(Some(format!("{}: enrollment ended, subscription {}", row.workspace, subscription.status))); |
| 1116 | } |
| 1117 | Ok(Some(format!("{}: enrollment subscription is {}", row.workspace, subscription.status))) |
| 1118 | } |
| 1119 | |
| 1120 | /// The conversion done at Stripe: the enrollment ends, the same |
| 1121 | /// subscription becomes the workspace's plan, and its first invoice is |
| 1122 | /// the plan's payment. |
| 1123 | async fn finish_conversion(&self, row: &Row, subscription: &StripeSubscription, by: &str) -> Result<()> { |
| 1124 | self.end(row, "converted", by, "the owner started the g1t plan").await?; |
| 1125 | self.record(&row.workspace, Feature::Plan, subscription, by).await?; |
| 1126 | // Best effort: Stripe's view of what the subscription is now. |
| 1127 | if let Some(stripe) = &self.stripe { |
| 1128 | let _ = stripe |
| 1129 | .post::<serde_json::Value>( |
| 1130 | &format!("/subscriptions/{}", subscription.id), |
| 1131 | &[("metadata[feature]", "plan".to_owned()), ("metadata[g1t_converted_from]", row.grant_id.clone())], |
| 1132 | ) |
| 1133 | .await; |
| 1134 | } |
| 1135 | Ok(()) |
| 1136 | } |
| 1137 | |
| 1138 | /// `convert_to_paid`. |
| 1139 | pub(crate) async fn convert_to_paid(&self, a: ConvertToPaidArgs) -> Result<Outcome<Conversion>> { |
| 1140 | let workspace = a.workspace.trim().to_lowercase(); |
| 1141 | if !a.actor.manages_billing(&workspace) { |
| 1142 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner or a billing manager can start the g1t plan.")); |
| 1143 | } |
| 1144 | let Some(stripe) = &self.stripe else { |
| 1145 | return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t.")); |
| 1146 | }; |
| 1147 | let Some(row) = self.complimentary_active(&workspace).await? else { |
| 1148 | return Ok(Outcome::fail(FailureCode::NotFound, "The workspace has no complimentary enrollment to convert.")); |
| 1149 | }; |
| 1150 | if fully_discounted(&row, self.internal_terms(&workspace).await?) { |
| 1151 | return Ok(Outcome::fail(FailureCode::Conflict, "This enrollment is g1t's own and is not converted.")); |
| 1152 | } |
| 1153 | let (Some(subscription_id), Some(item), Some(customer)) = (row.subscription_id.clone(), row.item_id.clone(), row.customer_id.clone()) else { |
| 1154 | return Ok(Outcome::fail(FailureCode::Conflict, "The enrollment's Stripe subscription is not set up yet; try again in a few minutes.")); |
| 1155 | }; |
| 1156 | // Where the subscription stands now decides: converted already, or |
| 1157 | // a payment waiting on the bank, is answered, never started again. |
| 1158 | let current = stripe.subscription(&subscription_id).await?; |
| 1159 | let converting = matches!(Op::parse(row.pending_op.as_deref()), Some(Op::Convert { .. })); |
| 1160 | if converted(row.price_id.as_deref(), ¤t, self.plans.plan_monthly_cents, converting) { |
| 1161 | self.finish_conversion(&row, ¤t, &a.actor.username).await?; |
| 1162 | return Ok(Outcome::Ok(Conversion { status: "converted".into(), url: None, message: "The g1t plan is on.".into() })); |
| 1163 | } |
| 1164 | let attempt = match Op::parse(row.pending_op.as_deref()) { |
| 1165 | Some(Op::Convert { attempt, .. }) => { |
| 1166 | if current.pending_update.as_ref().is_some_and(|p| !p.is_null()) { |
| 1167 | return self.waiting(stripe, ¤t).await; |
| 1168 | } |
| 1169 | attempt + 1 |
| 1170 | } |
| 1171 | Some(_) => { |
| 1172 | return Ok(Outcome::fail(FailureCode::Conflict, "g1t is still updating this enrollment at Stripe; try again in a few minutes.")); |
| 1173 | } |
| 1174 | None => 1, |
| 1175 | }; |
| 1176 | // A card to charge, with its billing address for tax: none yet, the |
| 1177 | // card check page asks for both and charges nothing. |
| 1178 | let saved = stripe.default_payment_method(&customer).await?; |
| 1179 | let Some(saved) = saved else { |
| 1180 | return self.needs_card(&a, "Add a card to start the plan; nothing is charged until you confirm.").await; |
| 1181 | }; |
| 1182 | stripe.set_default_card(&customer, &saved.id).await?; |
| 1183 | // Durable before Stripe is asked: a crash after this is picked up |
| 1184 | // with the same attempt and key. |
| 1185 | let started_ms = now_ms(); |
| 1186 | let op = Op::Convert { attempt, by: a.actor.username.clone(), started_ms }; |
| 1187 | let claimed = self |
| 1188 | .db |
| 1189 | .prepare( |
| 1190 | "UPDATE complimentary SET pending_op = ?2, updated_at = ?3 |
| 1191 | WHERE workspace = ?1 AND status = 'active' AND (pending_op IS NULL OR pending_op LIKE '{\"op\":\"convert\"%') |
| 1192 | RETURNING workspace", |
| 1193 | ) |
| 1194 | .bind(&[workspace.as_str().into(), op.text().into(), rfc3339(now_ms()).into()])? |
| 1195 | .first::<serde_json::Value>(None) |
| 1196 | .await?; |
| 1197 | if claimed.is_none() { |
| 1198 | return Ok(Outcome::fail(FailureCode::Conflict, "g1t is still updating this enrollment at Stripe; try again in a few minutes.")); |
| 1199 | } |
| 1200 | // Charged automatically from now, taxed: both change nothing on a |
| 1201 | // $0 subscription, and neither can wait on a payment. |
| 1202 | let prepared = stripe |
| 1203 | .post_idempotent::<serde_json::Value>( |
| 1204 | &format!("/subscriptions/{subscription_id}"), |
| 1205 | &[("collection_method", "charge_automatically".to_owned()), ("automatic_tax[enabled]", "true".to_owned())], |
| 1206 | &format!("cmp/{}/convert/{attempt}/{started_ms}/prepare", row.grant_id), |
| 1207 | ) |
| 1208 | .await; |
| 1209 | if let Err(error) = prepared { |
| 1210 | worker::console_error!("{workspace}: preparing the conversion failed: {error}"); |
| 1211 | self.clear_op(&row, &op).await?; |
| 1212 | if crate::stripe::is_tax_location_error(&error) { |
| 1213 | return self.needs_card(&a, "Add your billing address with a card so tax can be worked out; nothing is charged until you confirm.").await; |
| 1214 | } |
| 1215 | return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))); |
| 1216 | } |
| 1217 | let product = stripe.product(Feature::Plan.as_str(), &format!("{} plan", Feature::Plan.title())).await?; |
| 1218 | let fields = conversion_fields(&item, &product, self.plans.plan_monthly_cents); |
| 1219 | let changed: std::result::Result<StripeSubscription, _> = stripe |
| 1220 | .post_idempotent( |
| 1221 | &format!("/subscriptions/{subscription_id}"), |
| 1222 | &owned(&fields), |
| 1223 | &format!("cmp/{}/convert/{attempt}/{started_ms}", row.grant_id), |
| 1224 | ) |
| 1225 | .await; |
| 1226 | let changed = match changed { |
| 1227 | Ok(changed) => changed, |
| 1228 | Err(error) => { |
| 1229 | worker::console_error!("{workspace}: converting to the plan failed: {error}"); |
| 1230 | // A definite refusal with nothing waiting at Stripe leaves |
| 1231 | // nothing pending; a pending update (its invoice can still |
| 1232 | // be paid for 23 hours) or anything else is settled later. |
| 1233 | let refused = crate::invoices::is_decline(&error.to_string()) || error.to_string().contains("answered 400"); |
| 1234 | let waiting = stripe.subscription(&subscription_id).await.map_or(true, |s| s.pending_update.as_ref().is_some_and(|p| !p.is_null())); |
| 1235 | if refused && !waiting { |
| 1236 | self.clear_op(&row, &op).await?; |
| 1237 | } |
| 1238 | return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))); |
| 1239 | } |
| 1240 | }; |
| 1241 | if converted(row.price_id.as_deref(), &changed, self.plans.plan_monthly_cents, true) { |
| 1242 | self.finish_conversion(&row, &changed, &a.actor.username).await?; |
| 1243 | return Ok(Outcome::Ok(Conversion { status: "converted".into(), url: None, message: "The g1t plan is on.".into() })); |
| 1244 | } |
| 1245 | self.waiting(stripe, &changed).await |
| 1246 | } |
| 1247 | |
| 1248 | /// A conversion waiting on its first payment: where to finish it. |
| 1249 | async fn waiting(&self, stripe: &Stripe, subscription: &StripeSubscription) -> Result<Outcome<Conversion>> { |
| 1250 | let invoice: Option<serde_json::Value> = match &subscription.latest_invoice { |
| 1251 | Some(id) => Some(stripe.get(&format!("/invoices/{id}?expand[]=payment_intent")).await?), |
| 1252 | None => None, |
| 1253 | }; |
| 1254 | let url = invoice.as_ref().and_then(|i| i["hosted_invoice_url"].as_str()).map(str::to_owned); |
| 1255 | let intent = invoice.as_ref().and_then(|i| i["payment_intent"]["status"].as_str()).unwrap_or_default().to_owned(); |
| 1256 | Ok(Outcome::Ok(if intent == "requires_action" { |
| 1257 | Conversion { |
| 1258 | status: "needs_action".into(), |
| 1259 | url, |
| 1260 | message: "Your bank wants to confirm the payment. Finish it on Stripe's page; the plan starts once it is paid, and nothing changes until then.".into(), |
| 1261 | } |
| 1262 | } else { |
| 1263 | Conversion { |
| 1264 | status: "declined".into(), |
| 1265 | url, |
| 1266 | message: "The card was declined, so nothing changed: the complimentary terms stand. Pay with another card on Stripe's page to start the plan.".into(), |
| 1267 | } |
| 1268 | })) |
| 1269 | } |
| 1270 | |
| 1271 | async fn needs_card(&self, a: &ConvertToPaidArgs, message: &str) -> Result<Outcome<Conversion>> { |
| 1272 | let page = self |
| 1273 | .card_check(CardCheckArgs { actor: a.actor.clone(), workspace: a.workspace.clone(), return_url: a.return_url.clone() }) |
| 1274 | .await?; |
| 1275 | Ok(match page { |
| 1276 | Outcome::Ok(checkout) => Outcome::Ok(Conversion { status: "needs_card".into(), url: Some(checkout.url), message: message.to_owned() }), |
| 1277 | Outcome::Fail(failure) => Outcome::Fail(failure), |
| 1278 | }) |
| 1279 | } |
| 1280 | |
| 1281 | } |
| 1282 | |
| 1283 | /// The conversion itself: the one item to the plan's price, a new cycle |
| 1284 | /// from now, charged at once, applied only if the payment succeeds. |
| 1285 | pub(crate) fn conversion_fields(item: &str, product: &str, plan_cents: u32) -> Vec<(String, String)> { |
| 1286 | vec![ |
| 1287 | ("items[0][id]".into(), item.to_owned()), |
| 1288 | ("items[0][price_data][currency]".into(), "usd".into()), |
| 1289 | ("items[0][price_data][product]".into(), product.to_owned()), |
| 1290 | ("items[0][price_data][unit_amount]".into(), plan_cents.to_string()), |
| 1291 | ("items[0][price_data][recurring][interval]".into(), "month".into()), |
| 1292 | ("items[0][price_data][tax_behavior]".into(), "exclusive".into()), |
| 1293 | ("payment_behavior".into(), "pending_if_incomplete".into()), |
| 1294 | ("proration_behavior".into(), "always_invoice".into()), |
| 1295 | ("billing_cycle_anchor".into(), "now".into()), |
| 1296 | ] |
| 1297 | } |
| 1298 | |
| 1299 | /// Where this month stands against the allowance, from 50%: used, metered |
| 1300 | /// and held together, as admission counts them. |
| 1301 | pub(crate) fn alert(workspace: &str, month: &ComplimentaryMonth) -> Option<UsageAlert> { |
| 1302 | if month.unlimited || month.allowance_micros <= 0 { |
| 1303 | return None; |
| 1304 | } |
| 1305 | let taken = month.used_micros + month.pending_micros + month.held_micros; |
| 1306 | let level = crate::limits::alert_level(taken, month.allowance_micros); |
| 1307 | (level > 0).then(|| UsageAlert { |
| 1308 | meter: "complimentary".into(), |
| 1309 | level, |
| 1310 | used_micros: taken, |
| 1311 | limit_micros: month.allowance_micros, |
| 1312 | message: if level >= 100 && month.charge_past_allowance { |
| 1313 | format!( |
| 1314 | "{workspace} has used all of this month's {} complimentary allowance. Usage from here is charged to the workspace's card at the usual prices, up to its spend limit{}.", |
| 1315 | dollars(month.allowance_micros), |
| 1316 | if month.has_card { "" } else { "; add a card on Billing to keep going" } |
| 1317 | ) |
| 1318 | } else if level >= 100 { |
| 1319 | format!( |
| 1320 | "{workspace} has used all of this month's {} complimentary allowance. New work that costs money starts again on {}; an owner can start the g1t plan to keep going now.", |
| 1321 | dollars(month.allowance_micros), |
| 1322 | &month.resets_at[..10] |
| 1323 | ) |
| 1324 | } else if month.charge_past_allowance { |
| 1325 | format!( |
| 1326 | "{workspace} has used {} of this month's {} complimentary allowance ({level}%). Past it, usage is charged to the workspace's card at the usual prices.", |
| 1327 | dollars(taken), |
| 1328 | dollars(month.allowance_micros) |
| 1329 | ) |
| 1330 | } else { |
| 1331 | format!( |
| 1332 | "{workspace} has used {} of this month's {} complimentary allowance ({level}%). Past it, new work that costs money stops until {}; nothing is ever charged for it.", |
| 1333 | dollars(taken), |
| 1334 | dollars(month.allowance_micros), |
| 1335 | &month.resets_at[..10] |
| 1336 | ) |
| 1337 | }, |
| 1338 | }) |
| 1339 | } |
| 1340 | |
| 1341 | |
| 1342 | #[cfg(test)] |
| 1343 | mod tests { |
| 1344 | use super::*; |
| 1345 | |
| 1346 | fn row(allowance: i64, unlimited: bool, fee: i64) -> Row { |
| 1347 | Row { |
| 1348 | workspace: "acme".into(), |
| 1349 | grant_id: "cmp_1".into(), |
| 1350 | status: "active".into(), |
| 1351 | allowance_micros: allowance, |
| 1352 | unlimited: i64::from(unlimited), |
| 1353 | fee_cents: fee, |
| 1354 | overage: "stop".into(), |
| 1355 | live: 0, |
| 1356 | customer_id: Some("cus_1".into()), |
| 1357 | subscription_id: Some("sub_1".into()), |
| 1358 | item_id: Some("si_1".into()), |
| 1359 | price_id: Some("price_comp".into()), |
| 1360 | stripe_status: Some("active".into()), |
| 1361 | pending_op: None, |
| 1362 | last_error: None, |
| 1363 | note: "partner".into(), |
| 1364 | created_by: "staff".into(), |
| 1365 | created_at: "2026-10-01T00:00:00.000Z".into(), |
| 1366 | created_ms: 0, |
| 1367 | updated_by: "staff".into(), |
| 1368 | updated_at: "2026-10-01T00:00:00.000Z".into(), |
| 1369 | ended_at: None, |
| 1370 | ended_reason: None, |
| 1371 | } |
| 1372 | } |
| 1373 | |
| 1374 | fn args(allowance: i64, unlimited: bool, fee: u32) -> AdminSetComplimentaryArgs { |
| 1375 | AdminSetComplimentaryArgs { |
| 1376 | workspace: "acme".into(), |
| 1377 | allowance_micros: allowance, |
| 1378 | unlimited, |
| 1379 | fee_cents: fee, |
| 1380 | charge_past_allowance: false, |
| 1381 | note: "partner deal".into(), |
| 1382 | by: "staff".into(), |
| 1383 | } |
| 1384 | } |
| 1385 | |
| 1386 | #[test] |
| 1387 | fn staff_terms_are_bounded_and_say_why() { |
| 1388 | assert!(invalid(&args(100_000_000, false, 0)).is_none()); |
| 1389 | assert!(invalid(&args(500_000_000, false, 5_000)).is_none()); |
| 1390 | assert!(invalid(&args(0, true, 0)).is_none()); |
| 1391 | assert!(invalid(&args(0, false, 2_500)).is_none(), "a fee alone is custom terms"); |
| 1392 | assert!(invalid(&args(0, false, 0)).unwrap().contains("gives nothing")); |
| 1393 | assert!(invalid(&args(-1, false, 0)).is_some()); |
| 1394 | assert!(invalid(&args(MAX_COMPLIMENTARY_MICROS + 1, false, 0)).unwrap().contains("unlimited")); |
| 1395 | assert!(invalid(&args(1, false, MAX_COMPLIMENTARY_FEE_CENTS + 1)).is_some()); |
| 1396 | let mut quiet = args(1, false, 0); |
| 1397 | quiet.note = " ".into(); |
| 1398 | assert!(invalid(&quiet).unwrap().contains("why")); |
| 1399 | } |
| 1400 | |
| 1401 | #[test] |
| 1402 | fn remaining_counts_used_pending_and_held_and_never_goes_below_nothing() { |
| 1403 | let r = row(100_000_000, false, 0); |
| 1404 | let m = month_standing("2026-10", &r, 60_000_000, 25_000_000, 10_000_000, 0, false); |
| 1405 | assert_eq!(m.remaining_micros, 5_000_000); |
| 1406 | assert_eq!(m.resets_at, "2026-11-01T00:00:00Z"); |
| 1407 | // Lowered below what was used: nothing left, never a debt. |
| 1408 | let lowered = row(50_000_000, false, 0); |
| 1409 | assert_eq!(month_standing("2026-10", &lowered, 60_000_000, 0, 0, 0, false).remaining_micros, 0); |
| 1410 | // Unlimited ignores pending and held. |
| 1411 | let all = row(0, true, 0); |
| 1412 | assert!(month_standing("2026-12", &all, 9_000_000, 1, 1, 0, false).remaining_micros > 1_000_000_000); |
| 1413 | assert_eq!(month_standing("2026-12", &all, 0, 0, 0, 0, false).resets_at, "2027-01-01T00:00:00Z"); |
| 1414 | } |
| 1415 | |
| 1416 | #[test] |
| 1417 | fn a_waived_fee_is_a_zero_price_and_g1t_s_own_is_fully_discounted() { |
| 1418 | let waived = create_fields(&row(100_000_000, false, 0), "cus_1", "prod_plan", 2_100, false); |
| 1419 | let get = |fields: &[(String, String)], key: &str| fields.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()); |
| 1420 | assert_eq!(get(&waived, "items[0][price_data][unit_amount]").as_deref(), Some("0")); |
| 1421 | assert_eq!(get(&waived, "metadata[g1t_grant]").as_deref(), Some("cmp_1")); |
| 1422 | assert_eq!(get(&waived, "metadata[g1t_allowance_micros]").as_deref(), Some("100000000")); |
| 1423 | assert!(get(&waived, "discounts[0][coupon]").is_none()); |
| 1424 | assert!(get(&waived, "collection_method").is_none(), "nothing to collect"); |
| 1425 | let custom = create_fields(&row(500_000_000, false, 5_000), "cus_1", "prod_plan", 2_100, false); |
| 1426 | assert_eq!(get(&custom, "items[0][price_data][unit_amount]").as_deref(), Some("5000")); |
| 1427 | assert_eq!(get(&custom, "collection_method").as_deref(), Some("send_invoice")); |
| 1428 | assert_eq!(get(&custom, "automatic_tax[enabled]").as_deref(), Some("true")); |
| 1429 | // Only g1t's own (internal terms) is fully discounted; anyone else |
| 1430 | // unlimited with no fee is a $0 price, and may convert. |
| 1431 | assert!(fully_discounted(&row(0, true, 0), true)); |
| 1432 | assert!(!fully_discounted(&row(0, true, 0), false)); |
| 1433 | assert!(!fully_discounted(&row(0, true, 100), true)); |
| 1434 | let own = create_fields(&row(0, true, 0), "cus_1", "prod_plan", 2_100, true); |
| 1435 | assert_eq!(get(&own, "items[0][price_data][unit_amount]").as_deref(), Some("2100")); |
| 1436 | assert_eq!(get(&own, "discounts[0][coupon]").as_deref(), Some(FULL_DISCOUNT_COUPON)); |
| 1437 | } |
| 1438 | |
| 1439 | fn subscription(json: serde_json::Value) -> StripeSubscription { |
| 1440 | serde_json::from_value(json).unwrap() |
| 1441 | } |
| 1442 | |
| 1443 | #[test] |
| 1444 | fn a_conversion_counts_only_once_stripe_applied_it() { |
| 1445 | let item = |price: &str| serde_json::json!({"data": [{"id": "si_1", "price": {"id": price, "unit_amount": 2100}}]}); |
| 1446 | let converted = |price: Option<&str>, s: &StripeSubscription| converted(price, s, 2_100, true); |
| 1447 | // Paid: the item has the plan's price and nothing waits. |
| 1448 | assert!(converted(Some("price_comp"), &subscription(serde_json::json!({"id": "sub_1", "status": "active", "items": item("price_plan")})))); |
| 1449 | // A staff terms change gives the item a new price too: never a |
| 1450 | // conversion without one in progress, nor at another amount. |
| 1451 | let terms = subscription(serde_json::json!({"id": "sub_1", "status": "active", "items": item("price_plan")})); |
| 1452 | assert!(!super::converted(Some("price_comp"), &terms, 2_100, false)); |
| 1453 | let fee = subscription(serde_json::json!({"id": "sub_1", "status": "active", "items": {"data": [{"id": "si_1", "price": {"id": "price_fee", "unit_amount": 2500}}]}})); |
| 1454 | assert!(!super::converted(Some("price_comp"), &fee, 2_100, true)); |
| 1455 | // 3DS or declined: Stripe keeps the old price behind a pending update. |
| 1456 | let waiting = subscription(serde_json::json!({"id": "sub_1", "status": "active", "items": item("price_comp"), "pending_update": {"subscription_items": []}})); |
| 1457 | assert!(!converted(Some("price_comp"), &waiting)); |
| 1458 | let pending_new = subscription(serde_json::json!({"id": "sub_1", "status": "active", "items": item("price_plan"), "pending_update": {"x": 1}})); |
| 1459 | assert!(!converted(Some("price_comp"), &pending_new)); |
| 1460 | // Unchanged, canceled, or nothing known to compare with. |
| 1461 | assert!(!converted(Some("price_comp"), &subscription(serde_json::json!({"id": "sub_1", "status": "active", "items": item("price_comp")})))); |
| 1462 | assert!(!converted(Some("price_comp"), &subscription(serde_json::json!({"id": "sub_1", "status": "canceled", "items": item("price_plan")})))); |
| 1463 | assert!(!converted(None, &subscription(serde_json::json!({"id": "sub_1", "status": "active", "items": item("price_plan")})))); |
| 1464 | } |
| 1465 | |
| 1466 | #[test] |
| 1467 | fn a_conversion_waits_on_its_payment() { |
| 1468 | let fields = conversion_fields("si_1", "prod_plan", 2_100); |
| 1469 | let get = |key: &str| fields.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str()); |
| 1470 | assert_eq!(get("payment_behavior"), Some("pending_if_incomplete")); |
| 1471 | assert_eq!(get("items[0][id]"), Some("si_1")); |
| 1472 | assert_eq!(get("items[0][price_data][unit_amount]"), Some("2100")); |
| 1473 | assert_eq!(get("billing_cycle_anchor"), Some("now")); |
| 1474 | } |
| 1475 | |
| 1476 | #[test] |
| 1477 | fn alerts_count_what_admission_counts() { |
| 1478 | let r = row(100_000_000, false, 0); |
| 1479 | assert!(alert("acme", &month_standing("2026-10", &r, 40_000_000, 0, 0, 0, false)).is_none()); |
| 1480 | let half = alert("acme", &month_standing("2026-10", &r, 30_000_000, 15_000_000, 5_000_000, 0, false)).unwrap(); |
| 1481 | assert_eq!((half.meter.as_str(), half.level, half.used_micros), ("complimentary", 50, 50_000_000)); |
| 1482 | let done = alert("acme", &month_standing("2026-10", &r, 100_000_000, 0, 0, 0, false)).unwrap(); |
| 1483 | assert_eq!(done.level, 100); |
| 1484 | assert!(done.message.contains("2026-11-01") && !done.message.contains("charged at")); |
| 1485 | assert!(alert("acme", &month_standing("2026-10", &row(0, true, 0), 1, 0, 0, 0, false)).is_none()); |
| 1486 | } |
| 1487 | |
| 1488 | #[test] |
| 1489 | fn owed_steps_round_trip() { |
| 1490 | for op in [Op::Create, Op::Cancel, Op::Terms { period: "cmpp_1".into() }, Op::Convert { attempt: 2, by: "ana".into(), started_ms: 5 }] { |
| 1491 | assert_eq!(Op::parse(Some(&op.text())), Some(op)); |
| 1492 | } |
| 1493 | assert_eq!(Op::Create.text(), r#"{"op":"create"}"#); |
| 1494 | assert!(Op::Convert { attempt: 1, by: "a".into(), started_ms: 0 }.text().starts_with(r#"{"op":"convert""#)); |
| 1495 | assert_eq!(Op::parse(Some("nonsense")), None); |
| 1496 | } |
| 1497 | } |