Skip to content
603 linesCodeBlameRaw

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.

Paid features: a workspace turns on Deployments with a monthly plan1//! The card processor, behind the calls billing needs: start a payment
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2//! page, save and verify a card, ask whether a payment was made, and start,
3//! read or end the monthly plan. Stripe speaks form-encoded requests and
4//! JSON answers.
5//!
6//! Every Stripe object billing needs beyond customers and their payments
7//! (the plan's product, the billing page's settings) is made the first time
8//! it is needed, and found again by its `metadata[g1t]` after that.
Agents as a team: lifecycle, merge queue, billing and a new shell9
10use serde::Deserialize;
11use worker::{Error, Fetch, Headers, Method, Request, RequestInit, Result};
12
13const API: &str = "https://api.stripe.com/v1";
14
15pub struct Stripe {
16 key: String,
17}
18
19/// A payment page, and the payment made through it.
20#[derive(Deserialize)]
21pub struct Session {
22 pub id: String,
23 /// Where to send the person. Absent once the page has been used.
24 pub url: Option<String>,
25 /// `paid` once the money has been taken.
26 pub payment_status: String,
27 /// What was paid, in cents.
28 pub amount_total: Option<u32>,
29 pub customer: Option<String>,
Paid features: a workspace turns on Deployments with a monthly plan30 /// For a plan's page: the subscription it started.
31 #[serde(default)]
32 pub subscription: Option<String>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look33 /// For a card check's page: the setup that saved and verified the card.
34 #[serde(default)]
35 pub setup_intent: Option<String>,
36}
37
38/// A card saved and verified: what a card check found.
39#[derive(Debug, Deserialize)]
40pub struct CheckedCard {
41 pub payment_method: String,
42 pub fingerprint: Option<String>,
43 pub brand: Option<String>,
44 pub last4: Option<String>,
45 /// `credit`, `debit`, `prepaid` or `unknown`.
46 pub funding: Option<String>,
47 pub country: Option<String>,
Agents as a team: lifecycle, merge queue, billing and a new shell48}
49
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace50/// g1t's settings for Stripe's hosted billing page.
51#[derive(Debug, Deserialize)]
52pub struct PortalConfiguration {
53 pub id: String,
54 #[serde(default)]
55 pub login_page: Option<LoginPage>,
56 #[serde(default)]
57 pub metadata: Option<std::collections::HashMap<String, String>>,
58}
59
60#[derive(Debug, Deserialize)]
61pub struct LoginPage {
62 pub url: Option<String>,
63}
64
65/// A saved card's details.
66#[derive(Debug, Deserialize)]
67pub struct SavedCard {
68 pub brand: String,
69 pub last4: String,
70 pub exp_month: u32,
71 pub exp_year: u32,
72}
73
Paid features: a workspace turns on Deployments with a monthly plan74/// A monthly plan.
75#[derive(Deserialize)]
76pub struct StripeSubscription {
77 pub id: String,
78 /// `active`, `trialing`, `past_due`, `unpaid`, `canceled`, `incomplete`…
79 pub status: String,
80 #[serde(default)]
81 pub cancel_at_period_end: bool,
82 /// Unix seconds. Older API versions carry it here…
83 #[serde(default)]
84 pub current_period_end: Option<i64>,
85 /// …newer ones on each item.
86 #[serde(default)]
87 pub items: Option<Items>,
88}
89
90#[derive(Deserialize)]
91pub struct Items {
92 pub data: Vec<Item>,
93}
94
95#[derive(Deserialize)]
96pub struct Item {
97 #[serde(default)]
98 pub current_period_end: Option<i64>,
99}
100
101impl StripeSubscription {
102 /// When the period paid for ends, in Unix seconds.
103 pub fn period_end(&self) -> Option<i64> {
104 self.current_period_end.or_else(|| {
105 self.items
106 .as_ref()
107 .and_then(|items| items.data.iter().filter_map(|item| item.current_period_end).max())
108 })
109 }
110}
111
Agents as a team: lifecycle, merge queue, billing and a new shell112/// Percent-encodes a form value.
113fn encode(value: &str) -> String {
114 let mut encoded = String::with_capacity(value.len());
115 for byte in value.bytes() {
116 match byte {
117 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
118 encoded.push(byte as char);
119 }
120 _ => encoded.push_str(&format!("%{byte:02X}")),
121 }
122 }
123 encoded
124}
125
126/// `name=value` pairs as a form body.
127pub(crate) fn form(fields: &[(&str, String)]) -> String {
128 fields
129 .iter()
130 .map(|(name, value)| format!("{}={}", encode(name), encode(value)))
131 .collect::<Vec<_>>()
132 .join("&")
133}
134
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging135/// The idempotency key for starting a plan on a saved card: the same
136/// workspace, plan and card within the same ten minutes is one subscription,
137/// however many times it is asked for (a double click, two tabs), so a
138/// workspace is never billed twice for one plan. A different card is a new
139/// attempt, as Stripe refuses a key reused with other fields.
140pub(crate) fn plan_key(workspace: &str, feature: &str, payment_method: &str, now_ms: u64) -> String {
141 format!("plan/{workspace}/{feature}/{payment_method}/{}", now_ms / 600_000)
142}
143
Agents as a team: lifecycle, merge queue, billing and a new shell144impl Stripe {
145 pub fn new(key: String) -> Self {
146 Stripe { key }
147 }
148
149 /// Whether the key is for real cards, not Stripe's test mode.
150 pub fn live(&self) -> bool {
151 is_live(&self.key)
152 }
153
Stripe webhooks, enterprise invoices, and sudo for both154 /// A GET of any Stripe resource, for the webhook handlers.
155 pub(crate) async fn get<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> {
156 self.call(Method::Get, path, None).await
157 }
158
159 /// A form POST to any Stripe resource.
160 pub(crate) async fn post<T: for<'a> Deserialize<'a>>(&self, path: &str, fields: &[(&str, String)]) -> Result<T> {
161 self.call(Method::Post, path, Some(form(fields))).await
162 }
163
Two limits, real invoices, trust that grows by itself, sales signals164 /// A form POST that Stripe does at most once for `key`, however often
165 /// it is sent.
166 pub(crate) async fn post_idempotent<T: for<'a> Deserialize<'a>>(
167 &self,
168 path: &str,
169 fields: &[(&str, String)],
170 key: &str,
171 ) -> Result<T> {
172 self.send(Method::Post, path, Some(form(fields)), Some(key)).await
173 }
174
Agents as a team: lifecycle, merge queue, billing and a new shell175 async fn call<T: for<'a> Deserialize<'a>>(
176 &self,
177 method: Method,
178 path: &str,
179 body: Option<String>,
180 ) -> Result<T> {
Billing accounts, terms and enterprises; g1t is no longer free181 self.send(method, path, body, None).await
182 }
183
184 async fn send<T: for<'a> Deserialize<'a>>(
185 &self,
186 method: Method,
187 path: &str,
188 body: Option<String>,
189 idempotency_key: Option<&str>,
190 ) -> Result<T> {
Agents as a team: lifecycle, merge queue, billing and a new shell191 let headers = Headers::new();
192 headers.set("authorization", &format!("Bearer {}", self.key))?;
Billing accounts, terms and enterprises; g1t is no longer free193 if let Some(key) = idempotency_key {
194 headers.set("idempotency-key", key)?;
195 }
Agents as a team: lifecycle, merge queue, billing and a new shell196 if body.is_some() {
197 headers.set("content-type", "application/x-www-form-urlencoded")?;
198 }
199 let mut init = RequestInit::new();
200 init.with_method(method).with_headers(headers);
201 if let Some(body) = body {
202 init.with_body(Some(body.into()));
203 }
204 let request = Request::new_with_init(&format!("{API}{path}"), &init)?;
205 let mut response = Fetch::Request(request).send().await?;
206 if response.status_code() != 200 {
207 return Err(Error::RustError(format!(
208 "the card processor answered {}: {}",
209 response.status_code(),
210 response.text().await.unwrap_or_default()
211 )));
212 }
213 response.json().await
Billing accounts, terms and enterprises; g1t is no longer free214 }
215
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace216 /// A customer for a workspace that has none yet.
217 pub async fn create_customer(&self, workspace: &str) -> Result<String> {
218 #[derive(Deserialize)]
219 struct Customer {
220 id: String,
221 }
222 let fields = [
223 ("name", workspace.to_owned()),
224 ("metadata[workspace]", workspace.to_owned()),
225 ];
226 let customer: Customer = self.call(Method::Post, "/customers", Some(form(&fields))).await?;
227 Ok(customer.id)
228 }
229
230 /// A session on Stripe's hosted billing page (the customer portal) for
231 /// the customer, coming back to `return_url`.
232 pub async fn portal_session(&self, customer: &str, return_url: &str) -> Result<String> {
233 #[derive(Deserialize)]
234 struct Portal {
235 url: String,
236 }
237 let configuration = self.portal_configuration().await?;
238 let fields = [
239 ("customer", customer.to_owned()),
240 ("return_url", return_url.to_owned()),
241 ("configuration", configuration.id),
242 ];
243 let portal: Portal = self.call(Method::Post, "/billing_portal/sessions", Some(form(&fields))).await?;
244 Ok(portal.url)
245 }
246
247 /// g1t's billing page settings at Stripe, made the first time they are
248 /// needed: cards, invoices, billing details, and a sign-in page.
249 pub async fn portal_configuration(&self) -> Result<PortalConfiguration> {
250 #[derive(Deserialize)]
251 struct List {
252 data: Vec<PortalConfiguration>,
253 }
254 let list: List = self
255 .call(Method::Get, "/billing_portal/configurations?active=true&limit=20", None)
256 .await?;
257 if let Some(existing) = list
258 .data
259 .into_iter()
260 .find(|c| c.metadata.as_ref().and_then(|m| m.get("g1t")).is_some())
261 {
262 return Ok(existing);
263 }
264 let fields = [
265 ("business_profile[headline]", "g1t billing: your card, invoices and billing details".to_owned()),
266 ("features[payment_method_update][enabled]", "true".to_owned()),
267 ("features[invoice_history][enabled]", "true".to_owned()),
268 ("features[customer_update][enabled]", "true".to_owned()),
269 ("features[customer_update][allowed_updates][0]", "email".to_owned()),
270 ("features[customer_update][allowed_updates][1]", "address".to_owned()),
271 ("features[customer_update][allowed_updates][2]", "name".to_owned()),
272 ("features[customer_update][allowed_updates][3]", "tax_id".to_owned()),
273 ("login_page[enabled]", "true".to_owned()),
274 ("metadata[g1t]", "billing".to_owned()),
275 ];
276 self.call(Method::Post, "/billing_portal/configurations", Some(form(&fields))).await
277 }
278
279 /// The customer's email at Stripe, if they gave one.
280 pub async fn customer_email(&self, customer: &str) -> Result<Option<String>> {
281 #[derive(Deserialize)]
282 struct Customer {
283 email: Option<String>,
284 }
285 let found: Customer = self.call(Method::Get, &format!("/customers/{}", encode(customer)), None).await?;
286 Ok(found.email)
287 }
288
289 /// The customer's card, if one is saved.
290 pub async fn card(&self, customer: &str) -> Result<Option<SavedCard>> {
291 #[derive(Deserialize)]
292 struct Methods {
293 data: Vec<Method_>,
294 }
295 #[derive(Deserialize)]
296 struct Method_ {
297 card: Option<SavedCard>,
298 }
299 let methods: Methods = self
300 .call(Method::Get, &format!("/payment_methods?customer={}&type=card&limit=1", encode(customer)), None)
301 .await?;
302 Ok(methods.data.into_iter().next().and_then(|m| m.card))
303 }
304
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look305 /// Starts a page on which `amount_cents` is paid in advance: by card,
306 /// with 3-D Secure asked for wherever the card supports it, the card
307 /// kept for later charges; or, with `bank_transfer` and a customer, by
308 /// bank transfer to the account details Stripe gives, counted when the
309 /// money arrives.
Agents as a team: lifecycle, merge queue, billing and a new shell310 pub async fn start_checkout(
311 &self,
312 workspace: &str,
313 amount_cents: u32,
314 customer: Option<&str>,
315 return_url: &str,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look316 bank_transfer: bool,
Agents as a team: lifecycle, merge queue, billing and a new shell317 ) -> Result<Session> {
318 let separator = if return_url.contains('?') { '&' } else { '?' };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look319 let mut fields = vec![("mode", "payment".to_owned())];
320 if bank_transfer {
321 fields.extend([
322 ("payment_method_types[0]", "customer_balance".to_owned()),
323 ("payment_method_options[customer_balance][funding_type]", "bank_transfer".to_owned()),
324 ("payment_method_options[customer_balance][bank_transfer][type]", "us_bank_transfer".to_owned()),
325 ]);
326 } else {
327 fields.extend([
328 ("payment_method_types[0]", "card".to_owned()),
329 ("payment_method_options[card][request_three_d_secure]", "any".to_owned()),
330 ("payment_intent_data[setup_future_usage]", "off_session".to_owned()),
331 ]);
332 }
333 fields.extend([
Agents as a team: lifecycle, merge queue, billing and a new shell334 (
335 "success_url",
336 // Stripe fills in the payment's id.
337 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
338 ),
339 ("cancel_url", return_url.to_owned()),
340 ("client_reference_id", workspace.to_owned()),
341 ("metadata[workspace]", workspace.to_owned()),
342 ("line_items[0][quantity]", "1".to_owned()),
343 ("line_items[0][price_data][currency]", "usd".to_owned()),
344 (
345 "line_items[0][price_data][unit_amount]",
346 amount_cents.to_string(),
347 ),
348 (
349 "line_items[0][price_data][product_data][name]",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look350 format!("g1t usage paid in advance for {workspace}"),
Agents as a team: lifecycle, merge queue, billing and a new shell351 ),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look352 ]);
Agents as a team: lifecycle, merge queue, billing and a new shell353 match customer {
354 Some(customer) => fields.push(("customer", customer.to_owned())),
355 None => fields.push(("customer_creation", "always".to_owned())),
356 }
357 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
358 .await
359 }
360
Paid features: a workspace turns on Deployments with a monthly plan361 /// Starts a page on which a feature's monthly plan is paid for by card.
362 pub async fn start_subscription(
363 &self,
364 workspace: &str,
365 feature: &str,
366 title: &str,
367 monthly_cents: u32,
368 customer: Option<&str>,
369 return_url: &str,
370 ) -> Result<Session> {
371 let separator = if return_url.contains('?') { '&' } else { '?' };
372 let mut fields = vec![
373 ("mode", "subscription".to_owned()),
374 ("payment_method_types[0]", "card".to_owned()),
375 (
376 "success_url",
377 format!("{return_url}{separator}session={{CHECKOUT_SESSION_ID}}"),
378 ),
379 ("cancel_url", return_url.to_owned()),
380 ("client_reference_id", workspace.to_owned()),
381 ("metadata[workspace]", workspace.to_owned()),
382 ("metadata[feature]", feature.to_owned()),
383 ("subscription_data[metadata][workspace]", workspace.to_owned()),
384 ("subscription_data[metadata][feature]", feature.to_owned()),
385 ("line_items[0][quantity]", "1".to_owned()),
386 ("line_items[0][price_data][currency]", "usd".to_owned()),
387 (
388 "line_items[0][price_data][unit_amount]",
389 monthly_cents.to_string(),
390 ),
391 (
392 "line_items[0][price_data][recurring][interval]",
393 "month".to_owned(),
394 ),
395 (
396 "line_items[0][price_data][product_data][name]",
397 format!("g1t {title} for {workspace}"),
398 ),
399 ];
400 if let Some(customer) = customer {
401 fields.push(("customer", customer.to_owned()));
402 }
403 self.call(Method::Post, "/checkout/sessions", Some(form(&fields)))
404 .await
405 }
406
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look407 /// Starts a page that saves and verifies a card, with 3-D Secure asked
408 /// for wherever the card supports it. Nothing is charged: the card's
409 /// bank sees at most a $0 or $1 authorization that is never captured.
410 pub async fn start_card_check(&self, workspace: &str, customer: &str, return_url: &str) -> Result<Session> {
411 let separator = if return_url.contains('?') { '&' } else { '?' };
412 let fields = [
413 ("mode", "setup".to_owned()),
414 ("customer", customer.to_owned()),
415 ("payment_method_types[0]", "card".to_owned()),
416 ("payment_method_options[card][request_three_d_secure]", "any".to_owned()),
417 ("success_url", format!("{return_url}{separator}card_check={{CHECKOUT_SESSION_ID}}")),
418 ("cancel_url", return_url.to_owned()),
419 ("client_reference_id", workspace.to_owned()),
420 ("metadata[workspace]", workspace.to_owned()),
421 ("metadata[purpose]", "card_check".to_owned()),
422 ("setup_intent_data[metadata][workspace]", workspace.to_owned()),
423 ("setup_intent_data[description]", format!("Card check for g1t workspace {workspace}; never charged")),
424 ];
425 self.call(Method::Post, "/checkout/sessions", Some(form(&fields))).await
426 }
427
428 /// What a card check's setup found, once it succeeded.
429 pub async fn checked_card(&self, setup_intent: &str) -> Result<Option<CheckedCard>> {
430 #[derive(Deserialize)]
431 struct Setup {
432 status: String,
433 payment_method: Option<String>,
434 }
435 #[derive(Deserialize)]
436 struct Card {
437 fingerprint: Option<String>,
438 brand: Option<String>,
439 last4: Option<String>,
440 funding: Option<String>,
441 country: Option<String>,
442 }
443 #[derive(Deserialize)]
444 struct PaymentMethod {
445 card: Option<Card>,
446 }
447 let setup: Setup = self.call(Method::Get, &format!("/setup_intents/{}", encode(setup_intent)), None).await?;
448 let (true, Some(method)) = (setup.status == "succeeded", setup.payment_method) else { return Ok(None) };
449 let found: PaymentMethod = self.call(Method::Get, &format!("/payment_methods/{}", encode(&method)), None).await?;
450 let card = found.card;
451 Ok(Some(CheckedCard {
452 payment_method: method,
453 fingerprint: card.as_ref().and_then(|c| c.fingerprint.clone()),
454 brand: card.as_ref().and_then(|c| c.brand.clone()),
455 last4: card.as_ref().and_then(|c| c.last4.clone()),
456 funding: card.as_ref().and_then(|c| c.funding.clone()),
457 country: card.as_ref().and_then(|c| c.country.clone()),
458 }))
459 }
460
461 /// Makes `payment_method` the card the customer's invoices are charged to.
462 pub async fn set_default_card(&self, customer: &str, payment_method: &str) -> Result<()> {
463 let _: serde_json::Value = self
464 .call(
465 Method::Post,
466 &format!("/customers/{}", encode(customer)),
467 Some(form(&[("invoice_settings[default_payment_method]", payment_method.to_owned())])),
468 )
469 .await?;
470 Ok(())
471 }
472
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar473 /// A feature's product at Stripe (the plan's is tagged `plan`), made
474 /// the first time it is needed.
475 async fn plan_product(&self, feature: &str, title: &str) -> Result<String> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look476 #[derive(Deserialize)]
477 struct Product {
478 id: String,
479 #[serde(default)]
480 metadata: Option<std::collections::HashMap<String, String>>,
481 }
482 #[derive(Deserialize)]
483 struct List {
484 data: Vec<Product>,
485 }
486 let list: List = self.call(Method::Get, "/products?active=true&limit=100", None).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar487 let ours = |p: &Product| p.metadata.as_ref().and_then(|m| m.get("g1t")).map(String::as_str) == Some(feature);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look488 if let Some(found) = list.data.into_iter().find(ours) {
489 return Ok(found.id);
490 }
491 let created: Product = self
492 .call(
493 Method::Post,
494 "/products",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar495 Some(form(&[("name", format!("{title} plan")), ("metadata[g1t]", feature.to_owned())])),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look496 )
497 .await?;
498 Ok(created.id)
499 }
500
501 /// Starts the monthly plan on a saved card, at once. Fails rather than
502 /// leaving it half-started when the card's bank wants the person again;
503 /// the caller then sends them to Stripe's page.
504 pub async fn subscribe_with_card(
505 &self,
506 workspace: &str,
507 feature: &str,
508 title: &str,
509 monthly_cents: u32,
510 customer: &str,
511 payment_method: &str,
512 ) -> Result<StripeSubscription> {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar513 let product = self.plan_product(feature, title).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look514 let fields = [
515 ("customer", customer.to_owned()),
516 ("default_payment_method", payment_method.to_owned()),
517 ("payment_behavior", "error_if_incomplete".to_owned()),
518 ("items[0][price_data][currency]", "usd".to_owned()),
519 ("items[0][price_data][product]", product),
520 ("items[0][price_data][unit_amount]", monthly_cents.to_string()),
521 ("items[0][price_data][recurring][interval]", "month".to_owned()),
522 ("metadata[workspace]", workspace.to_owned()),
523 ("metadata[feature]", feature.to_owned()),
524 ("description", format!("{title} plan for {workspace}")),
525 ];
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging526 let key = plan_key(workspace, feature, payment_method, g1t_kit::now_ms());
527 self.send(Method::Post, "/subscriptions", Some(form(&fields)), Some(&key)).await
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look528 }
529
530 /// Ends a subscription now: one that never started properly.
531 pub async fn cancel_now(&self, id: &str) -> Result<StripeSubscription> {
532 self.call(Method::Delete, &format!("/subscriptions/{}", encode(id)), None).await
533 }
534
Paid features: a workspace turns on Deployments with a monthly plan535 pub async fn subscription(&self, id: &str) -> Result<StripeSubscription> {
536 self.call(Method::Get, &format!("/subscriptions/{}", encode(id)), None)
537 .await
538 }
539
540 /// Ends a plan when its period does (`cancel` true), or takes that back.
541 pub async fn cancel_at_period_end(&self, id: &str, cancel: bool) -> Result<StripeSubscription> {
542 self.call(
543 Method::Post,
544 &format!("/subscriptions/{}", encode(id)),
545 Some(form(&[("cancel_at_period_end", cancel.to_string())])),
546 )
547 .await
548 }
549
Agents as a team: lifecycle, merge queue, billing and a new shell550 pub async fn session(&self, id: &str) -> Result<Session> {
551 self.call(
552 Method::Get,
553 &format!("/checkout/sessions/{}", encode(id)),
554 None,
555 )
556 .await
557 }
558}
559
Project dependencies: addresses, preview stacks, Affects, and agents who know560/// Whether the processor said an id it was given does not exist, as when
561/// g1t moves to another Stripe account and ids saved from the old one stay
562/// behind.
563pub(crate) fn is_missing(error: &Error) -> bool {
564 error.to_string().contains("resource_missing")
565}
566
Agents as a team: lifecycle, merge queue, billing and a new shell567pub(crate) fn is_live(key: &str) -> bool {
568 key.starts_with("sk_live_") || key.starts_with("rk_live_")
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574
575 #[test]
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging576 fn asking_twice_for_a_plan_starts_one() {
577 let at = 1_791_000_000_000;
578 assert_eq!(plan_key("acme", "plan", "pm_1", at), plan_key("acme", "plan", "pm_1", at + 1_000));
579 assert_ne!(plan_key("acme", "plan", "pm_1", at), plan_key("acme", "plan", "pm_2", at));
580 assert_ne!(plan_key("acme", "plan", "pm_1", at), plan_key("other", "plan", "pm_1", at));
581 }
582
583 #[test]
Agents as a team: lifecycle, merge queue, billing and a new shell584 fn form_values_are_percent_encoded() {
585 assert_eq!(
586 form(&[
587 (
588 "success_url",
589 "https://g1t.sh/a/-/billing?session={ID}".to_owned()
590 ),
591 ("line_items[0][quantity]", "1".to_owned()),
592 ]),
593 "success_url=https%3A%2F%2Fg1t.sh%2Fa%2F-%2Fbilling%3Fsession%3D%7BID%7D&line_items%5B0%5D%5Bquantity%5D=1"
594 );
595 }
596
597 #[test]
598 fn test_keys_are_not_live() {
599 assert!(is_live("sk_live_abc"));
600 assert!(!is_live("sk_test_abc"));
601 assert!(!is_live(""));
602 }
603}