pr_01m47d24b0e6n91zwymwxg0vpx/services/billing/src/cards.rs

268 lines12,078 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 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)]
254mod 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}