Skip to content

g1t/services/billing/src/cards.rs

265 lines12,331 bytesCodeBlame

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 look1//! 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;
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA19use g1t_contracts::{FailureCode, Outcome};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look20use 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();
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA41 if !a.actor.manages_billing(&workspace) {
42 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner or a billing manager can check a card for the workspace."));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look43 }
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,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit49 Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look50 };
Usage, Billing settings and prepaid AI credit; fixes from the UX audit51 let started = match stripe.start_card_check(&workspace, &customer, &a.return_url).await {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look52 Err(error) if crate::stripe::is_missing(&error) => {
53 self.forget_customer(&workspace).await?;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit54 match self.customer_for(&workspace).await {
55 Ok(customer) => stripe.start_card_check(&workspace, &customer, &a.return_url).await,
56 Err(error) => Err(error),
57 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look58 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit59 other => other,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look60 };
Usage, Billing settings and prepaid AI credit; fixes from the UX audit61 self.page_opened(started, &workspace, 0, 0, &a.actor.username, Some(CARD_CHECK)).await
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look62 }
63
64 /// `confirm_card_check`: records the check once Stripe says it passed.
65 pub(crate) async fn confirm_card_check(&self, a: ConfirmCardCheckArgs) -> Result<Outcome<Entitlements>> {
66 let workspace = a.workspace.to_lowercase();
67 if !a.viewer.is_some_and(|viewer| viewer.is_member(&workspace)) {
68 return Ok(members_only());
69 }
70 let mine = self
71 .db
72 .prepare("SELECT workspace FROM checkouts WHERE id = ? AND workspace = ? AND feature = ?")
73 .bind(&[a.session.as_str().into(), workspace.as_str().into(), CARD_CHECK.into()])?
74 .first::<serde_json::Value>(None)
75 .await?;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit76 if mine.is_some() {
77 match self.settle_card_check(&a.session).await {
78 Ok(Ok(_)) => {}
79 Ok(Err(why)) => return Ok(Outcome::fail(FailureCode::Conflict, why)),
80 Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, crate::stripe::friendly(&error))),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look81 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit82 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look83 Ok(Outcome::Ok(self.entitlements(EntitlementsArgs { workspace }).await?))
84 }
85
86 /// Records a card check whose page is done, once, and grants the trial
87 /// if the card has not had one and this month's pool has room. Returns
88 /// what happened, or why the check did not pass.
89 pub(crate) async fn settle_card_check(&self, session_id: &str) -> Result<std::result::Result<String, String>> {
90 #[derive(Deserialize)]
91 struct Open {
92 workspace: String,
93 created_by: String,
94 status: String,
95 }
96 let Some(open) = self
97 .db
98 .prepare("SELECT workspace, created_by, status FROM checkouts WHERE id = ? AND feature = ?")
99 .bind(&[session_id.into(), CARD_CHECK.into()])?
100 .first::<Open>(None)
101 .await?
102 else {
103 return Ok(Ok("ignored: not a card check".to_owned()));
104 };
105 if open.status != "open" {
106 return Ok(Ok("ignored: already settled".to_owned()));
107 }
108 let Some(stripe) = &self.stripe else { return Ok(Ok("ignored: payments off".to_owned())) };
109 let session = stripe.session(session_id).await?;
110 let Some(setup) = session.setup_intent.as_deref() else {
111 return Ok(Err("The card check is not finished yet.".to_owned()));
112 };
113 let Some(card) = stripe.checked_card(setup).await? else {
114 return Ok(Err("The card could not be verified. Try another card, or try again.".to_owned()));
115 };
116 let claimed = self
117 .db
118 .prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open' RETURNING id")
119 .bind(&[session_id.into()])?
120 .first::<serde_json::Value>(None)
121 .await?;
122 if claimed.is_none() {
123 return Ok(Ok("ignored: settled meanwhile".to_owned()));
124 }
125 let workspace = open.workspace;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person126 // The address entered with the card, onto a customer that has none,
127 // so the plan and invoices that follow can be taxed.
128 if let (Some(customer), Some(address)) = (session.customer.as_deref(), card.address.as_ref()) {
129 match stripe.fill_address(customer, address).await {
130 Ok(true) => self.tax_address_given(&workspace).await?,
131 Ok(false) => {}
132 Err(error) => worker::console_error!("{workspace}: the card's billing address was not saved on the customer: {error}"),
133 }
134 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look135 let now = rfc3339(now_ms());
136 #[derive(Deserialize)]
137 struct Count {
138 n: Option<u32>,
139 }
140 let elsewhere = match card.fingerprint.as_deref() {
141 Some(fingerprint) => self
142 .db
143 .prepare("SELECT COUNT(*) AS n FROM card_checks WHERE fingerprint = ? AND workspace <> ?")
144 .bind(&[fingerprint.into(), workspace.as_str().into()])?
145 .first::<Count>(None)
146 .await?
147 .and_then(|c| c.n)
148 .unwrap_or(0),
149 None => 0,
150 };
151 self.db
152 .prepare(
153 "INSERT INTO card_checks (workspace, setup_intent, payment_method, fingerprint, brand, last4, funding, country, checked_by, checked_at)
154 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
155 ON CONFLICT (workspace) DO UPDATE SET setup_intent = ?2, payment_method = ?3, fingerprint = ?4, brand = ?5,
156 last4 = ?6, funding = ?7, country = ?8, checked_by = ?9, checked_at = ?10",
157 )
158 .bind(&[
159 workspace.as_str().into(),
160 setup.into(),
161 card.payment_method.as_str().into(),
162 crate::optional(card.fingerprint.as_deref()),
163 crate::optional(card.brand.as_deref()),
164 crate::optional(card.last4.as_deref()),
165 crate::optional(card.funding.as_deref()),
166 crate::optional(card.country.as_deref()),
167 open.created_by.as_str().into(),
168 now.as_str().into(),
169 ])?
170 .run()
171 .await?;
172 // The card the plan and later charges use.
173 if let Some(customer) = session.customer.as_deref() {
174 if let Err(error) = stripe.set_default_card(customer, &card.payment_method).await {
175 worker::console_error!("{workspace}: the checked card was not made the default: {error}");
176 }
Billing keeps Stripe's view itself: the saved card on the account, missed events replayed every 15 minutes, and the endpoint kept177 // The page this lands on shows the new card, not the old.
178 self.forget_card(&workspace).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look179 self.db
180 .prepare("UPDATE accounts SET customer_id = COALESCE(customer_id, ?2) WHERE workspace = ?1")
181 .bind(&[workspace.as_str().into(), customer.into()])?
182 .run()
183 .await?;
184 }
185 let account = self.account_of(&workspace).await?;
186 let trial = if trial_for_card(card.fingerprint.as_deref(), card.funding.as_deref(), elsewhere) {
187 match self.ensure_grant(&workspace).await? {
188 Some(grant) => format!("trial of {} granted", crate::features::dollars(grant.granted_micros)),
189 None => "no trial: this month's pool is given out".to_owned(),
190 }
191 } else if card.funding.as_deref() == Some("prepaid") {
192 "no trial: prepaid cards cannot start one".to_owned()
193 } else {
194 "no trial: the card had one for another workspace".to_owned()
195 };
196 self.audit(
197 &account.id,
198 "card_check",
199 &format!(
200 "{workspace}: {} ending {} checked ({}); {trial}",
201 card.brand.as_deref().unwrap_or("card"),
202 card.last4.as_deref().unwrap_or("????"),
203 card.funding.as_deref().unwrap_or("unknown")
204 ),
205 &open.created_by,
206 )
207 .await?;
208 Ok(Ok(format!("{workspace}: card checked; {trial}")))
209 }
210
211 /// Whether the workspace's checked card may start a trial: it has a
212 /// fingerprint, and no other workspace checked the same card before.
213 pub(crate) async fn trial_allowed(&self, workspace: &str) -> Result<bool> {
214 #[derive(Deserialize)]
215 struct Row {
216 fingerprint: Option<String>,
217 funding: Option<String>,
218 earlier: Option<u32>,
219 }
220 let row = self
221 .db
222 .prepare(
223 "SELECT c.fingerprint AS fingerprint, c.funding AS funding,
224 (SELECT COUNT(*) FROM card_checks o
225 WHERE o.fingerprint = c.fingerprint AND o.workspace <> c.workspace AND o.checked_at <= c.checked_at) AS earlier
226 FROM card_checks c WHERE c.workspace = ?",
227 )
228 .bind(&[workspace.into()])?
229 .first::<Row>(None)
230 .await?;
231 Ok(row.is_some_and(|r| trial_for_card(r.fingerprint.as_deref(), r.funding.as_deref(), r.earlier.unwrap_or(0))))
232 }
233
234 /// The checked card's payment method, for starting the plan on it.
235 pub(crate) async fn checked_card(&self, workspace: &str) -> Result<Option<String>> {
236 #[derive(Deserialize)]
237 struct Row {
238 payment_method: String,
239 }
240 Ok(self
241 .db
242 .prepare("SELECT payment_method FROM card_checks WHERE workspace = ?")
243 .bind(&[workspace.into()])?
244 .first::<Row>(None)
245 .await?
246 .map(|row| row.payment_method))
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn one_trial_per_card() {
256 assert!(trial_for_card(Some("fp_1"), Some("credit"), 0));
257 assert!(trial_for_card(Some("fp_1"), Some("debit"), 0));
258 // The same card checked for another workspace first: no second trial.
259 assert!(!trial_for_card(Some("fp_1"), Some("credit"), 1));
260 // A card Stripe could not fingerprint gets no trial.
261 assert!(!trial_for_card(None, Some("credit"), 0));
262 // Prepaid cards are how trials get farmed: none.
263 assert!(!trial_for_card(Some("fp_2"), Some("prepaid"), 0));
264 }
265}

This file's history is long; its oldest lines are credited to the oldest commit read.