g1t/services/billing/src/cards.rs
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1 | //! The card check: a card saved and verified with Stripe, never charged. |
| 2 | //! | |
| 3 | //! Compute costs g1t real money from the first second, so a workspace | |
| 4 | //! without the plan needs a card check before its trial ($5 of usage, once) | |
| 5 | //! or g1t's open-source pool will pay for anything. It is the smallest gate | |
| 6 | //! that stops free compute being mined: a real card, one trial per card. | |
| 7 | //! | |
| 8 | //! The check is Stripe Checkout in setup mode with 3-D Secure asked for | |
| 9 | //! wherever the card supports it. The card's bank sees a $0 or $1 | |
| 10 | //! authorization that is never captured. The card is saved as the | |
| 11 | //! workspace's default, so starting the plan later needs no second page. | |
| 12 | //! | |
| 13 | //! It completes when the person comes back (`confirm_card_check`) or when | |
| 14 | //! Stripe says so (`checkout.session.completed`), whichever is first: both | |
| 15 | //! claim the same checkout row. | |
| 16 | ||
| 17 | use g1t_contracts::billing::{CardCheckArgs, Checkout, ConfirmCardCheckArgs, Entitlements, EntitlementsArgs}; | |
| 18 | use g1t_contracts::time::rfc3339; | |
| 19 | use g1t_contracts::{FailureCode, Outcome, Role}; | |
| 20 | use g1t_kit::now_ms; | |
| 21 | use serde::Deserialize; | |
| 22 | use worker::Result; | |
| 23 | ||
| 24 | use crate::{Billing, members_only}; | |
| 25 | ||
| 26 | /// What the checkout row for a card check is marked with. | |
| 27 | pub(crate) const CARD_CHECK: &str = "card_check"; | |
| 28 | ||
| 29 | /// Whether a card's trial is still to be had: one trial per card, | |
| 30 | /// whichever workspace it was checked for first, and never on a prepaid | |
| 31 | /// card, which anyone can buy as many of as they like to farm trials. A | |
| 32 | /// prepaid card still works for the plan and for paying. | |
| 33 | pub(crate) fn trial_for_card(fingerprint: Option<&str>, funding: Option<&str>, checked_elsewhere: u32) -> bool { | |
| 34 | fingerprint.is_some() && funding != Some("prepaid") && checked_elsewhere == 0 | |
| 35 | } | |
| 36 | ||
| 37 | impl Billing { | |
| 38 | /// `card_check`: Stripe's page to save and verify a card. | |
| 39 | pub(crate) async fn card_check(&self, a: CardCheckArgs) -> Result<Outcome<Checkout>> { | |
| 40 | let workspace = a.workspace.to_lowercase(); | |
| 41 | if a.actor.role_in(&workspace) != Some(Role::Owner) { | |
| 42 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can check a card for the workspace.")); | |
| 43 | } | |
| 44 | let Some(stripe) = &self.stripe else { | |
| 45 | return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t, so there is nothing to check.")); | |
| 46 | }; | |
| 47 | let customer = match self.customer_for(&workspace).await { | |
| 48 | Ok(customer) => customer, | |
| 49 | Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe could not be reached: {error}"))), | |
| 50 | }; | |
| 51 | let session = match stripe.start_card_check(&workspace, &customer, &a.return_url).await { | |
| 52 | Ok(session) => session, | |
| 53 | Err(error) if crate::stripe::is_missing(&error) => { | |
| 54 | self.forget_customer(&workspace).await?; | |
| 55 | let customer = self.customer_for(&workspace).await?; | |
| 56 | stripe.start_card_check(&workspace, &customer, &a.return_url).await? | |
| 57 | } | |
| 58 | Err(error) => return Err(error), | |
| 59 | }; | |
| 60 | let Some(url) = session.url else { | |
| 61 | return Err(worker::Error::RustError("Stripe returned no page for the card check".into())); | |
| 62 | }; | |
| 63 | self.db | |
| 64 | .prepare( | |
| 65 | "INSERT INTO checkouts (id, workspace, amount_cents, created_by, created_at, feature) | |
| 66 | VALUES (?, ?, 0, ?, ?, ?)", | |
| 67 | ) | |
| 68 | .bind(&[ | |
| 69 | session.id.as_str().into(), | |
| 70 | workspace.as_str().into(), | |
| 71 | a.actor.username.as_str().into(), | |
| 72 | rfc3339(now_ms()).into(), | |
| 73 | CARD_CHECK.into(), | |
| 74 | ])? | |
| 75 | .run() | |
| 76 | .await?; | |
| 77 | Ok(Outcome::Ok(Checkout { url })) | |
| 78 | } | |
| 79 | ||
| 80 | /// `confirm_card_check`: records the check once Stripe says it passed. | |
| 81 | pub(crate) async fn confirm_card_check(&self, a: ConfirmCardCheckArgs) -> Result<Outcome<Entitlements>> { | |
| 82 | let workspace = a.workspace.to_lowercase(); | |
| 83 | if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) { | |
| 84 | return Ok(members_only()); | |
| 85 | } | |
| 86 | let mine = self | |
| 87 | .db | |
| 88 | .prepare("SELECT workspace FROM checkouts WHERE id = ? AND workspace = ? AND feature = ?") | |
| 89 | .bind(&[a.session.as_str().into(), workspace.as_str().into(), CARD_CHECK.into()])? | |
| 90 | .first::<serde_json::Value>(None) | |
| 91 | .await?; | |
| 92 | if mine.is_some() { | |
| 93 | if let Err(why) = self.settle_card_check(&a.session).await? { | |
| 94 | return Ok(Outcome::fail(FailureCode::Conflict, why)); | |
| 95 | } | |
| 96 | } | |
| 97 | Ok(Outcome::Ok(self.entitlements(EntitlementsArgs { workspace }).await?)) | |
| 98 | } | |
| 99 | ||
| 100 | /// Records a card check whose page is done, once, and grants the trial | |
| 101 | /// if the card has not had one and this month's pool has room. Returns | |
| 102 | /// what happened, or why the check did not pass. | |
| 103 | pub(crate) async fn settle_card_check(&self, session_id: &str) -> Result<std::result::Result<String, String>> { | |
| 104 | #[derive(Deserialize)] | |
| 105 | struct Open { | |
| 106 | workspace: String, | |
| 107 | created_by: String, | |
| 108 | status: String, | |
| 109 | } | |
| 110 | let Some(open) = self | |
| 111 | .db | |
| 112 | .prepare("SELECT workspace, created_by, status FROM checkouts WHERE id = ? AND feature = ?") | |
| 113 | .bind(&[session_id.into(), CARD_CHECK.into()])? | |
| 114 | .first::<Open>(None) | |
| 115 | .await? | |
| 116 | else { | |
| 117 | return Ok(Ok("ignored: not a card check".to_owned())); | |
| 118 | }; | |
| 119 | if open.status != "open" { | |
| 120 | return Ok(Ok("ignored: already settled".to_owned())); | |
| 121 | } | |
| 122 | let Some(stripe) = &self.stripe else { return Ok(Ok("ignored: payments off".to_owned())) }; | |
| 123 | let session = stripe.session(session_id).await?; | |
| 124 | let Some(setup) = session.setup_intent.as_deref() else { | |
| 125 | return Ok(Err("The card check is not finished yet.".to_owned())); | |
| 126 | }; | |
| 127 | let Some(card) = stripe.checked_card(setup).await? else { | |
| 128 | return Ok(Err("The card could not be verified. Try another card, or try again.".to_owned())); | |
| 129 | }; | |
| 130 | let claimed = self | |
| 131 | .db | |
| 132 | .prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open' RETURNING id") | |
| 133 | .bind(&[session_id.into()])? | |
| 134 | .first::<serde_json::Value>(None) | |
| 135 | .await?; | |
| 136 | if claimed.is_none() { | |
| 137 | return Ok(Ok("ignored: settled meanwhile".to_owned())); | |
| 138 | } | |
| 139 | let workspace = open.workspace; | |
| 140 | let now = rfc3339(now_ms()); | |
| 141 | #[derive(Deserialize)] | |
| 142 | struct Count { | |
| 143 | n: Option<u32>, | |
| 144 | } | |
| 145 | let elsewhere = match card.fingerprint.as_deref() { | |
| 146 | Some(fingerprint) => self | |
| 147 | .db | |
| 148 | .prepare("SELECT COUNT(*) AS n FROM card_checks WHERE fingerprint = ? AND workspace <> ?") | |
| 149 | .bind(&[fingerprint.into(), workspace.as_str().into()])? | |
| 150 | .first::<Count>(None) | |
| 151 | .await? | |
| 152 | .and_then(|c| c.n) | |
| 153 | .unwrap_or(0), | |
| 154 | None => 0, | |
| 155 | }; | |
| 156 | self.db | |
| 157 | .prepare( | |
| 158 | "INSERT INTO card_checks (workspace, setup_intent, payment_method, fingerprint, brand, last4, funding, country, checked_by, checked_at) | |
| 159 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) | |
| 160 | ON CONFLICT (workspace) DO UPDATE SET setup_intent = ?2, payment_method = ?3, fingerprint = ?4, brand = ?5, | |
| 161 | last4 = ?6, funding = ?7, country = ?8, checked_by = ?9, checked_at = ?10", | |
| 162 | ) | |
| 163 | .bind(&[ | |
| 164 | workspace.as_str().into(), | |
| 165 | setup.into(), | |
| 166 | card.payment_method.as_str().into(), | |
| 167 | crate::optional(card.fingerprint.as_deref()), | |
| 168 | crate::optional(card.brand.as_deref()), | |
| 169 | crate::optional(card.last4.as_deref()), | |
| 170 | crate::optional(card.funding.as_deref()), | |
| 171 | crate::optional(card.country.as_deref()), | |
| 172 | open.created_by.as_str().into(), | |
| 173 | now.as_str().into(), | |
| 174 | ])? | |
| 175 | .run() | |
| 176 | .await?; | |
| 177 | // The card the plan and later charges use. | |
| 178 | if let Some(customer) = session.customer.as_deref() { | |
| 179 | if let Err(error) = stripe.set_default_card(customer, &card.payment_method).await { | |
| 180 | worker::console_error!("{workspace}: the checked card was not made the default: {error}"); | |
| 181 | } | |
| 182 | self.db | |
| 183 | .prepare("UPDATE accounts SET customer_id = COALESCE(customer_id, ?2) WHERE workspace = ?1") | |
| 184 | .bind(&[workspace.as_str().into(), customer.into()])? | |
| 185 | .run() | |
| 186 | .await?; | |
| 187 | } | |
| 188 | let account = self.account_of(&workspace).await?; | |
| 189 | let trial = if trial_for_card(card.fingerprint.as_deref(), card.funding.as_deref(), elsewhere) { | |
| 190 | match self.ensure_grant(&workspace).await? { | |
| 191 | Some(grant) => format!("trial of {} granted", crate::features::dollars(grant.granted_micros)), | |
| 192 | None => "no trial: this month's pool is given out".to_owned(), | |
| 193 | } | |
| 194 | } else if card.funding.as_deref() == Some("prepaid") { | |
| 195 | "no trial: prepaid cards cannot start one".to_owned() | |
| 196 | } else { | |
| 197 | "no trial: the card had one for another workspace".to_owned() | |
| 198 | }; | |
| 199 | self.audit( | |
| 200 | &account.id, | |
| 201 | "card_check", | |
| 202 | &format!( | |
| 203 | "{workspace}: {} ending {} checked ({}); {trial}", | |
| 204 | card.brand.as_deref().unwrap_or("card"), | |
| 205 | card.last4.as_deref().unwrap_or("????"), | |
| 206 | card.funding.as_deref().unwrap_or("unknown") | |
| 207 | ), | |
| 208 | &open.created_by, | |
| 209 | ) | |
| 210 | .await?; | |
| 211 | Ok(Ok(format!("{workspace}: card checked; {trial}"))) | |
| 212 | } | |
| 213 | ||
| 214 | /// Whether the workspace's checked card may start a trial: it has a | |
| 215 | /// fingerprint, and no other workspace checked the same card before. | |
| 216 | pub(crate) async fn trial_allowed(&self, workspace: &str) -> Result<bool> { | |
| 217 | #[derive(Deserialize)] | |
| 218 | struct Row { | |
| 219 | fingerprint: Option<String>, | |
| 220 | funding: Option<String>, | |
| 221 | earlier: Option<u32>, | |
| 222 | } | |
| 223 | let row = self | |
| 224 | .db | |
| 225 | .prepare( | |
| 226 | "SELECT c.fingerprint AS fingerprint, c.funding AS funding, | |
| 227 | (SELECT COUNT(*) FROM card_checks o | |
| 228 | WHERE o.fingerprint = c.fingerprint AND o.workspace <> c.workspace AND o.checked_at <= c.checked_at) AS earlier | |
| 229 | FROM card_checks c WHERE c.workspace = ?", | |
| 230 | ) | |
| 231 | .bind(&[workspace.into()])? | |
| 232 | .first::<Row>(None) | |
| 233 | .await?; | |
| 234 | Ok(row.is_some_and(|r| trial_for_card(r.fingerprint.as_deref(), r.funding.as_deref(), r.earlier.unwrap_or(0)))) | |
| 235 | } | |
| 236 | ||
| 237 | /// The checked card's payment method, for starting the plan on it. | |
| 238 | pub(crate) async fn checked_card(&self, workspace: &str) -> Result<Option<String>> { | |
| 239 | #[derive(Deserialize)] | |
| 240 | struct Row { | |
| 241 | payment_method: String, | |
| 242 | } | |
| 243 | Ok(self | |
| 244 | .db | |
| 245 | .prepare("SELECT payment_method FROM card_checks WHERE workspace = ?") | |
| 246 | .bind(&[workspace.into()])? | |
| 247 | .first::<Row>(None) | |
| 248 | .await? | |
| 249 | .map(|row| row.payment_method)) | |
| 250 | } | |
| 251 | } | |
| 252 | ||
| 253 | #[cfg(test)] | |
| 254 | mod tests { | |
| 255 | use super::*; | |
| 256 | ||
| 257 | #[test] | |
| 258 | fn one_trial_per_card() { | |
| 259 | assert!(trial_for_card(Some("fp_1"), Some("credit"), 0)); | |
| 260 | assert!(trial_for_card(Some("fp_1"), Some("debit"), 0)); | |
| 261 | // The same card checked for another workspace first: no second trial. | |
| 262 | assert!(!trial_for_card(Some("fp_1"), Some("credit"), 1)); | |
| 263 | // A card Stripe could not fingerprint gets no trial. | |
| 264 | assert!(!trial_for_card(None, Some("credit"), 0)); | |
| 265 | // Prepaid cards are how trials get farmed: none. | |
| 266 | assert!(!trial_for_card(Some("fp_2"), Some("prepaid"), 0)); | |
| 267 | } | |
| 268 | } |