g1t/services/billing/src/cards.rs

269 lines12,186 bytesCodeBlame
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
17use g1t_contracts::billing::{CardCheckArgs, Checkout, ConfirmCardCheckArgs, Entitlements, EntitlementsArgs};
18use g1t_contracts::time::rfc3339;
19use g1t_contracts::{FailureCode, Outcome, Role};
20use g1t_kit::now_ms;
21use serde::Deserialize;
22use worker::Result;
23
24use crate::{Billing, members_only};
25
26/// What the checkout row for a card check is marked with.
27pub(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.
33pub(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
37impl 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 && let Err(why) = self.settle_card_check(&a.session).await? {
94 return Ok(Outcome::fail(FailureCode::Conflict, why));
95 }
96 Ok(Outcome::Ok(self.entitlements(EntitlementsArgs { workspace }).await?))
97 }
98
99 /// Records a card check whose page is done, once, and grants the trial
100 /// if the card has not had one and this month's pool has room. Returns
101 /// what happened, or why the check did not pass.
102 pub(crate) async fn settle_card_check(&self, session_id: &str) -> Result<std::result::Result<String, String>> {
103 #[derive(Deserialize)]
104 struct Open {
105 workspace: String,
106 created_by: String,
107 status: String,
108 }
109 let Some(open) = self
110 .db
111 .prepare("SELECT workspace, created_by, status FROM checkouts WHERE id = ? AND feature = ?")
112 .bind(&[session_id.into(), CARD_CHECK.into()])?
113 .first::<Open>(None)
114 .await?
115 else {
116 return Ok(Ok("ignored: not a card check".to_owned()));
117 };
118 if open.status != "open" {
119 return Ok(Ok("ignored: already settled".to_owned()));
120 }
121 let Some(stripe) = &self.stripe else { return Ok(Ok("ignored: payments off".to_owned())) };
122 let session = stripe.session(session_id).await?;
123 let Some(setup) = session.setup_intent.as_deref() else {
124 return Ok(Err("The card check is not finished yet.".to_owned()));
125 };
126 let Some(card) = stripe.checked_card(setup).await? else {
127 return Ok(Err("The card could not be verified. Try another card, or try again.".to_owned()));
128 };
129 let claimed = self
130 .db
131 .prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open' RETURNING id")
132 .bind(&[session_id.into()])?
133 .first::<serde_json::Value>(None)
134 .await?;
135 if claimed.is_none() {
136 return Ok(Ok("ignored: settled meanwhile".to_owned()));
137 }
138 let workspace = open.workspace;
139 let now = rfc3339(now_ms());
140 #[derive(Deserialize)]
141 struct Count {
142 n: Option<u32>,
143 }
144 let elsewhere = match card.fingerprint.as_deref() {
145 Some(fingerprint) => self
146 .db
147 .prepare("SELECT COUNT(*) AS n FROM card_checks WHERE fingerprint = ? AND workspace <> ?")
148 .bind(&[fingerprint.into(), workspace.as_str().into()])?
149 .first::<Count>(None)
150 .await?
151 .and_then(|c| c.n)
152 .unwrap_or(0),
153 None => 0,
154 };
155 self.db
156 .prepare(
157 "INSERT INTO card_checks (workspace, setup_intent, payment_method, fingerprint, brand, last4, funding, country, checked_by, checked_at)
158 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
159 ON CONFLICT (workspace) DO UPDATE SET setup_intent = ?2, payment_method = ?3, fingerprint = ?4, brand = ?5,
160 last4 = ?6, funding = ?7, country = ?8, checked_by = ?9, checked_at = ?10",
161 )
162 .bind(&[
163 workspace.as_str().into(),
164 setup.into(),
165 card.payment_method.as_str().into(),
166 crate::optional(card.fingerprint.as_deref()),
167 crate::optional(card.brand.as_deref()),
168 crate::optional(card.last4.as_deref()),
169 crate::optional(card.funding.as_deref()),
170 crate::optional(card.country.as_deref()),
171 open.created_by.as_str().into(),
172 now.as_str().into(),
173 ])?
174 .run()
175 .await?;
176 // The card the plan and later charges use.
177 if let Some(customer) = session.customer.as_deref() {
178 if let Err(error) = stripe.set_default_card(customer, &card.payment_method).await {
179 worker::console_error!("{workspace}: the checked card was not made the default: {error}");
180 }
181 // The page this lands on shows the new card, not the old.
182 self.forget_card(&workspace).await?;
183 self.db
184 .prepare("UPDATE accounts SET customer_id = COALESCE(customer_id, ?2) WHERE workspace = ?1")
185 .bind(&[workspace.as_str().into(), customer.into()])?
186 .run()
187 .await?;
188 }
189 let account = self.account_of(&workspace).await?;
190 let trial = if trial_for_card(card.fingerprint.as_deref(), card.funding.as_deref(), elsewhere) {
191 match self.ensure_grant(&workspace).await? {
192 Some(grant) => format!("trial of {} granted", crate::features::dollars(grant.granted_micros)),
193 None => "no trial: this month's pool is given out".to_owned(),
194 }
195 } else if card.funding.as_deref() == Some("prepaid") {
196 "no trial: prepaid cards cannot start one".to_owned()
197 } else {
198 "no trial: the card had one for another workspace".to_owned()
199 };
200 self.audit(
201 &account.id,
202 "card_check",
203 &format!(
204 "{workspace}: {} ending {} checked ({}); {trial}",
205 card.brand.as_deref().unwrap_or("card"),
206 card.last4.as_deref().unwrap_or("????"),
207 card.funding.as_deref().unwrap_or("unknown")
208 ),
209 &open.created_by,
210 )
211 .await?;
212 Ok(Ok(format!("{workspace}: card checked; {trial}")))
213 }
214
215 /// Whether the workspace's checked card may start a trial: it has a
216 /// fingerprint, and no other workspace checked the same card before.
217 pub(crate) async fn trial_allowed(&self, workspace: &str) -> Result<bool> {
218 #[derive(Deserialize)]
219 struct Row {
220 fingerprint: Option<String>,
221 funding: Option<String>,
222 earlier: Option<u32>,
223 }
224 let row = self
225 .db
226 .prepare(
227 "SELECT c.fingerprint AS fingerprint, c.funding AS funding,
228 (SELECT COUNT(*) FROM card_checks o
229 WHERE o.fingerprint = c.fingerprint AND o.workspace <> c.workspace AND o.checked_at <= c.checked_at) AS earlier
230 FROM card_checks c WHERE c.workspace = ?",
231 )
232 .bind(&[workspace.into()])?
233 .first::<Row>(None)
234 .await?;
235 Ok(row.is_some_and(|r| trial_for_card(r.fingerprint.as_deref(), r.funding.as_deref(), r.earlier.unwrap_or(0))))
236 }
237
238 /// The checked card's payment method, for starting the plan on it.
239 pub(crate) async fn checked_card(&self, workspace: &str) -> Result<Option<String>> {
240 #[derive(Deserialize)]
241 struct Row {
242 payment_method: String,
243 }
244 Ok(self
245 .db
246 .prepare("SELECT payment_method FROM card_checks WHERE workspace = ?")
247 .bind(&[workspace.into()])?
248 .first::<Row>(None)
249 .await?
250 .map(|row| row.payment_method))
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn one_trial_per_card() {
260 assert!(trial_for_card(Some("fp_1"), Some("credit"), 0));
261 assert!(trial_for_card(Some("fp_1"), Some("debit"), 0));
262 // The same card checked for another workspace first: no second trial.
263 assert!(!trial_for_card(Some("fp_1"), Some("credit"), 1));
264 // A card Stripe could not fingerprint gets no trial.
265 assert!(!trial_for_card(None, Some("credit"), 0));
266 // Prepaid cards are how trials get farmed: none.
267 assert!(!trial_for_card(Some("fp_2"), Some("prepaid"), 0));
268 }
269}