Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
1,281 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.

Merge branch 'worktree-agent-a6a121745e81f639f'1//! The card processor, behind the calls billing needs: start a payment
2//! 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.
Usage, Billing settings and prepaid AI credit; fixes from the UX audit9//!
10//! Every request names the API version it is written for
11//! (`STRIPE_VERSION`). Without it Stripe answers at the account's default,
12//! which for an account made today is a newer version than this code reads:
13//! invoices no longer carry `subscription`, charges no longer carry
14//! `invoice`, and parameters this code sends can be refused. A failure is
15//! never shown raw: `friendly` turns it into a sentence for the page.
Merge branch 'worktree-agent-a6a121745e81f639f'16
17use serde::Deserialize;
18use worker::{Error, Fetch, Headers, Method, Request, RequestInit, Result};
19
20const API: &str = "https://api.stripe.com/v1";
21
Usage, Billing settings and prepaid AI credit; fixes from the UX audit22/// The Stripe API version billing is written against. Changing it is a
23/// code change: read Stripe's upgrade notes for every field billing reads.
24pub(crate) const STRIPE_VERSION: &str = "2025-02-24.acacia";
25
Merge Stripe Tax, the card fee on card payments, and one free workspace per person26/// Stripe Tax's code for what g1t sells, on every product, price and
27/// invoice line: software as a service, for business use. A card fee line
28/// carries it too, since a fee for paying for a sale is taxed as the sale.
29pub(crate) const TAX_CODE: &str = "txcd_10103001";
30
31/// What every payment page (payment or subscription mode) asks of Stripe
32/// Tax: tax worked out on top of the price shown, from a billing address
33/// it always collects, with the buyer's tax ID if they have one. With a
34/// customer already, what was entered is saved on it, so invoices and
35/// off-session charges later find the address too. Checkout refuses
36/// `automatic_tax` for a customer with no address unless it may save one
37/// (`customer_update[address]`), and `tax_id_collection` unless it may save
38/// the name (`customer_update[name]`).
39pub(crate) fn checkout_tax_fields(customer: Option<&str>) -> Vec<(&'static str, String)> {
40 let mut fields = vec![
41 ("automatic_tax[enabled]", "true".to_owned()),
42 ("billing_address_collection", "required".to_owned()),
43 ("tax_id_collection[enabled]", "true".to_owned()),
44 ];
45 if customer.is_some() {
46 fields.extend([("customer_update[address]", "auto".to_owned()), ("customer_update[name]", "auto".to_owned())]);
47 }
48 fields
49}
50
51/// Whether Stripe refused because it could not tell where the customer is
52/// for tax: no address, or not enough of one.
53pub(crate) fn is_tax_location_error(error: &Error) -> bool {
54 let text = error.to_string();
55 text.contains("customer_tax_location_invalid") || text.contains("requires_location_inputs")
56}
57
58/// Whether an address is enough for Stripe Tax to place the customer: a
59/// country, and in the United States a postal code (in Canada a postal
60/// code or a province).
61pub(crate) fn address_places_customer(address: &serde_json::Value) -> bool {
62 let text = |key: &str| address[key].as_str().map(str::trim).unwrap_or_default().to_owned();
63 match text("country").to_uppercase().as_str() {
64 "" => false,
65 "US" => !text("postal_code").is_empty(),
66 "CA" => !text("postal_code").is_empty() || !text("state").is_empty(),
67 _ => true,
68 }
69}
70
Usage, Billing settings and prepaid AI credit; fixes from the UX audit71/// What a Stripe failure says, for the person on the page: Stripe's own
72/// message when it gave one (never the request or any key), else that it
73/// could not be reached.
74pub(crate) fn friendly(error: &Error) -> String {
75 let text = error.to_string();
76 let message = text
77 .split_once(": ")
78 .and_then(|(_, body)| serde_json::from_str::<serde_json::Value>(body).ok())
79 .and_then(|body| body["error"]["message"].as_str().map(str::to_owned));
80 match message {
81 Some(message) => format!("Stripe refused it: {}", message.trim_end_matches('.').to_owned() + "."),
82 None if text.contains("the card processor answered") => "Stripe refused it. Try again in a minute; if it keeps happening, write to support@g1t.sh.".to_owned(),
83 None => "Stripe could not be reached. Try again in a minute.".to_owned(),
84 }
85}
86
Merge branch 'worktree-agent-a6a121745e81f639f'87pub struct Stripe {
88 key: String,
89}
90
91/// A payment page, and the payment made through it.
92#[derive(Deserialize)]
93pub struct Session {
94 pub id: String,
95 /// Where to send the person. Absent once the page has been used.
96 pub url: Option<String>,
97 /// `paid` once the money has been taken.
98 pub payment_status: String,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person99 /// What was paid, in cents, tax included.
Merge branch 'worktree-agent-a6a121745e81f639f'100 pub amount_total: Option<u32>,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person101 /// What the lines came to before tax, in cents.
102 #[serde(default)]
103 pub amount_subtotal: Option<u32>,
104 /// The tax Stripe added (`amount_tax`), among other totals.
105 #[serde(default)]
106 pub total_details: Option<TotalDetails>,
107 /// For a payment page: the payment that took the money.
108 #[serde(default)]
109 pub payment_intent: Option<String>,
Merge branch 'worktree-agent-a6a121745e81f639f'110 pub customer: Option<String>,
111 /// For a plan's page: the subscription it started.
112 #[serde(default)]
113 pub subscription: Option<String>,
114 /// For a card check's page: the setup that saved and verified the card.
115 #[serde(default)]
116 pub setup_intent: Option<String>,
117}
118
Merge Stripe Tax, the card fee on card payments, and one free workspace per person119#[derive(Default, Deserialize)]
120pub struct TotalDetails {
121 #[serde(default)]
122 pub amount_tax: i64,
123}
124
125impl Session {
126 /// The tax Stripe added on the page, in cents.
127 pub fn tax_cents(&self) -> i64 {
128 self.total_details.as_ref().map_or(0, |t| t.amount_tax.max(0))
129 }
130
131 /// What the page's lines came to before tax, in cents: the total less
132 /// the tax when Stripe gives no subtotal.
133 pub fn before_tax_cents(&self) -> i64 {
134 match self.amount_subtotal {
135 Some(subtotal) => i64::from(subtotal),
136 None => (i64::from(self.amount_total.unwrap_or(0)) - self.tax_cents()).max(0),
137 }
138 }
139}
140
Merge branch 'worktree-agent-a6a121745e81f639f'141/// A card saved and verified: what a card check found.
142#[derive(Debug, Deserialize)]
143pub struct CheckedCard {
144 pub payment_method: String,
145 pub fingerprint: Option<String>,
146 pub brand: Option<String>,
147 pub last4: Option<String>,
148 /// `credit`, `debit`, `prepaid` or `unknown`.
149 pub funding: Option<String>,
150 pub country: Option<String>,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person151 /// The billing address entered with the card, as Stripe keeps it.
152 #[serde(default)]
153 pub address: Option<serde_json::Value>,
Merge branch 'worktree-agent-a6a121745e81f639f'154}
155
156/// g1t's settings for Stripe's hosted billing page.
157#[derive(Debug, Deserialize)]
158pub struct PortalConfiguration {
159 pub id: String,
160 #[serde(default)]
161 pub login_page: Option<LoginPage>,
162 #[serde(default)]
163 pub metadata: Option<std::collections::HashMap<String, String>>,
164}
165
166#[derive(Debug, Deserialize)]
167pub struct LoginPage {
168 pub url: Option<String>,
169}
170
171/// A monthly plan.
172#[derive(Deserialize)]
173pub struct StripeSubscription {
174 pub id: String,
175 /// `active`, `trialing`, `past_due`, `unpaid`, `canceled`, `incomplete`…
176 pub status: String,
177 #[serde(default)]
178 pub cancel_at_period_end: bool,
179 /// Unix seconds. Older API versions carry it here…
180 #[serde(default)]
181 pub current_period_end: Option<i64>,
182 /// …newer ones on each item.
183 #[serde(default)]
184 pub items: Option<Items>,
185}
186
187#[derive(Deserialize)]
188pub struct Items {
189 pub data: Vec<Item>,
190}
191
192#[derive(Deserialize)]
193pub struct Item {
194 #[serde(default)]
195 pub current_period_end: Option<i64>,
196}
197
198impl StripeSubscription {
199 /// When the period paid for ends, in Unix seconds.
200 pub fn period_end(&self) -> Option<i64> {
201 self.current_period_end.or_else(|| {
202 self.items
203 .as_ref()
204 .and_then(|items| items.data.iter().filter_map(|item| item.current_period_end).max())
205 })
206 }
207}
208
209/// Percent-encodes a form value.
210fn encode(value: &str) -> String {
211 let mut encoded = String::with_capacity(value.len());
212 for byte in value.bytes() {
213 match byte {
214 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
215 encoded.push(byte as char);
216 }
217 _ => encoded.push_str(&format!("%{byte:02X}")),
218 }
219 }
220 encoded
221}
222
223/// `name=value` pairs as a form body.
Billing: ask Stripe for tax only where Stripe Tax is active224/// Requests that carry `automatic_tax`: payment pages, invoices and
225/// subscriptions. When Stripe Tax is not active for the key's mode (a test
226/// account where it was never turned on), Stripe refuses them outright, so
227/// the field is left out and the payment goes through untaxed.
228pub(crate) fn taxed_path(path: &str) -> bool {
229 path == "/checkout/sessions" || path == "/invoices" || path == "/subscriptions"
230}
231
232/// A form body without its `automatic_tax[…]` fields.
233pub(crate) fn without_automatic_tax(body: &str) -> String {
234 body.split('&').filter(|pair| !pair.starts_with("automatic_tax")).collect::<Vec<_>>().join("&")
235}
236
237/// How long whether Stripe Tax is active is believed, in milliseconds.
238const TAX_ACTIVE_FOR_MS: u64 = 10 * 60 * 1000;
239
240thread_local! {
241 /// Whether Stripe Tax is active for the key, and when that was read.
242 static TAX_ACTIVE: std::cell::Cell<Option<(bool, u64)>> = const { std::cell::Cell::new(None) };
243}
244
Merge branch 'worktree-agent-a6a121745e81f639f'245pub(crate) fn form(fields: &[(&str, String)]) -> String {
246 fields
247 .iter()
248 .map(|(name, value)| format!("{}={}", encode(name), encode(value)))
249 .collect::<Vec<_>>()
250 .join("&")
251}
252
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging253/// The idempotency key for starting a plan on a saved card: the same
254/// workspace, plan and card within the same ten minutes is one subscription,
255/// however many times it is asked for (a double click, two tabs), so a
256/// workspace is never billed twice for one plan. A different card is a new
257/// attempt, as Stripe refuses a key reused with other fields.
258pub(crate) fn plan_key(workspace: &str, feature: &str, payment_method: &str, now_ms: u64) -> String {
259 format!("plan/{workspace}/{feature}/{payment_method}/{}", now_ms / 600_000)
260}
261
Merge branch 'worktree-agent-a6a121745e81f639f'262impl Stripe {
263 pub fn new(key: String) -> Self {
264 Stripe { key }
265 }
266
267 /// Whether the key is for real cards, not Stripe's test mode.
268 pub fn live(&self) -> bool {
269 is_live(&self.key)
270 }
271
272 /// A GET of any Stripe resource, for the webhook handlers.
273 pub(crate) async fn get<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> {
274 self.call(Method::Get, path, None).await
275 }
276
Usage, Billing settings and prepaid AI credit; fixes from the UX audit277 /// A DELETE of any Stripe resource.
278 pub(crate) async fn delete<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> {
279 self.call(Method::Delete, path, None).await
280 }
281
Merge branch 'worktree-agent-a6a121745e81f639f'282 /// A form POST to any Stripe resource.
283 pub(crate) async fn post<T: for<'a> Deserialize<'a>>(&self, path: &str, fields: &[(&str, String)]) -> Result<T> {
284 self.call(Method::Post, path, Some(form(fields))).await
285 }
286
287 /// A form POST that Stripe does at most once for `key`, however often
288 /// it is sent.
289 pub(crate) async fn post_idempotent<T: for<'a> Deserialize<'a>>(
290 &self,
291 path: &str,
292 fields: &[(&str, String)],
293 key: &str,
294 ) -> Result<T> {
295 self.send(Method::Post, path, Some(form(fields)), Some(key)).await
296 }
297
298 async fn call<T: for<'a> Deserialize<'a>>(
299 &self,
300 method: Method,
301 path: &str,
302 body: Option<String>,
303 ) -> Result<T> {
304 self.send(method, path, body, None).await
305 }
306
307 async fn send<T: for<'a> Deserialize<'a>>(
308 &self,
309 method: Method,
310 path: &str,
311 body: Option<String>,
312 idempotency_key: Option<&str>,
313 ) -> Result<T> {
Billing: ask Stripe for tax only where Stripe Tax is active314 let body = match body {
315 Some(body) if matches!(method, Method::Post) && taxed_path(path) && body.contains("automatic_tax") && !self.tax_active().await => {
316 Some(without_automatic_tax(&body))
317 }
318 body => body,
319 };
320 self.send_raw(method, path, body, idempotency_key).await
321 }
322
323 /// The request itself, as given: `send` without the Stripe Tax check.
324 async fn send_raw<T: for<'a> Deserialize<'a>>(
325 &self,
326 method: Method,
327 path: &str,
328 body: Option<String>,
329 idempotency_key: Option<&str>,
330 ) -> Result<T> {
Merge branch 'worktree-agent-a6a121745e81f639f'331 let headers = Headers::new();
332 headers.set("authorization", &format!("Bearer {}", self.key))?;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit333 headers.set("stripe-version", STRIPE_VERSION)?;
Merge branch 'worktree-agent-a6a121745e81f639f'334 if let Some(key) = idempotency_key {
335 headers.set("idempotency-key", key)?;
336 }
337 if body.is_some() {
338 headers.set("content-type", "application/x-www-form-urlencoded")?;
339 }
340 let mut init = RequestInit::new();
341 init.with_method(method).with_headers(headers);
342 if let Some(body) = body {
343 init.with_body(Some(body.into()));
344 }
345 let request = Request::new_with_init(&format!("{API}{path}"), &init)?;
346 let mut response = Fetch::Request(request).send().await?;
347 if response.status_code() != 200 {
348 return Err(Error::RustError(format!(
349 "the card processor answered {}: {}",
350 response.status_code(),
351 response.text().await.unwrap_or_default()
352 )));
353 }
354 response.json().await
355 }
356
Billing: ask Stripe for tax only where Stripe Tax is active357 /// Whether Stripe Tax is active for this key's mode (`GET /tax/settings`,
358 /// `status` `active`), read at most every ten minutes. When it cannot be
359 /// read, it is taken as active: asking for tax and being refused says
360 /// why, where leaving it out would undercharge without a word.
361 pub async fn tax_active(&self) -> bool {
362 let now = crate::now_ms();
363 if let Some((active, at)) = TAX_ACTIVE.with(|cell| cell.get())
364 && now.saturating_sub(at) < TAX_ACTIVE_FOR_MS
365 {
366 return active;
367 }
368 #[derive(Deserialize)]
369 struct Settings {
370 status: String,
371 }
372 let active = match self.send_raw::<Settings>(Method::Get, "/tax/settings", None, None).await {
373 Ok(settings) => settings.status == "active",
374 Err(error) => {
375 worker::console_error!("Stripe Tax settings could not be read; asking for tax anyway: {error}");
376 true
377 }
378 };
379 TAX_ACTIVE.with(|cell| cell.set(Some((active, now))));
380 active
381 }
382
Merge branch 'worktree-agent-a6a121745e81f639f'383 /// A customer for a workspace that has none yet.
384 pub async fn create_customer(&self, workspace: &str) -> Result<String> {
385 #[derive(Deserialize)]
386 struct Customer {
387 id: String,
388 }
389 let fields = [
390 ("name", workspace.to_owned()),
391 ("metadata[workspace]", workspace.to_owned()),
392 ];
393 let customer: Customer = self.call(Method::Post, "/customers", Some(form(&fields))).await?;
394 Ok(customer.id)
395 }
396
397 /// A session on Stripe's hosted billing page (the customer portal) for
398 /// the customer, coming back to `return_url`.
399 pub async fn portal_session(&self, customer: &str, return_url: &str) -> Result<String> {
400 #[derive(Deserialize)]
401 struct Portal {
402 url: String,
403 }
404 let configuration = self.portal_configuration().await?;
405 let fields = [
406 ("customer", customer.to_owned()),
407 ("return_url", return_url.to_owned()),
408 ("configuration", configuration.id),
409 ];
410 let portal: Portal = self.call(Method::Post, "/billing_portal/sessions", Some(form(&fields))).await?;
411 Ok(portal.url)
412 }
413
414 /// g1t's billing page settings at Stripe, made the first time they are
415 /// needed: cards, invoices, billing details, and a sign-in page.
416 pub async fn portal_configuration(&self) -> Result<PortalConfiguration> {
417 #[derive(Deserialize)]
418 struct List {
419 data: Vec<PortalConfiguration>,
420 }
421 let list: List = self
422 .call(Method::Get, "/billing_portal/configurations?active=true&limit=20", None)
423 .await?;
424 if let Some(existing) = list
425 .data
426 .into_iter()
427 .find(|c| c.metadata.as_ref().and_then(|m| m.get("g1t")).is_some())
428 {
429 return Ok(existing);
430 }
431 let fields = [
432 ("business_profile[headline]", "g1t billing: your card, invoices and billing details".to_owned()),
433 ("features[payment_method_update][enabled]", "true".to_owned()),
434 ("features[invoice_history][enabled]", "true".to_owned()),
435 ("features[customer_update][enabled]", "true".to_owned()),
436 ("features[customer_update][allowed_updates][0]", "email".to_owned()),
437 ("features[customer_update][allowed_updates][1]", "address".to_owned()),
438 ("features[customer_update][allowed_updates][2]", "name".to_owned()),
439 ("features[customer_update][allowed_updates][3]", "tax_id".to_owned()),
440 ("login_page[enabled]", "true".to_owned()),
441 ("metadata[g1t]", "billing".to_owned()),
442 ];
443 self.call(Method::Post, "/billing_portal/configurations", Some(form(&fields))).await
444 }
445
446 /// The customer's email at Stripe, if they gave one.
447 pub async fn customer_email(&self, customer: &str) -> Result<Option<String>> {
448 #[derive(Deserialize)]
449 struct Customer {
450 email: Option<String>,
451 }
452 let found: Customer = self.call(Method::Get, &format!("/customers/{}", encode(customer)), None).await?;
453 Ok(found.email)
454 }
455
456 /// Starts a page on which `amount_cents` is paid in advance: by card,
457 /// with 3-D Secure asked for wherever the card supports it, the card
458 /// kept for later charges; or, with `bank_transfer` and a customer, by
459 /// bank transfer to the account details Stripe gives, counted when the
460 /// money arrives.
461 pub async fn start_checkout(
462 &self,
463 workspace: &str,
464 amount_cents: u32,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person465 fee_cents: u32,
Merge branch 'worktree-agent-a6a121745e81f639f'466 customer: Option<&str>,
467 return_url: &str,
468 bank_transfer: bool,
469 ) -> Result<Session> {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person470 let fields = prepay_fields(workspace, amount_cents, fee_cents, customer, return_url, bank_transfer);
471 let key = page_key(
472 "prepay",
473 workspace,
474 &format!("{amount_cents}/{fee_cents}/{bank_transfer}/{}", customer.unwrap_or("new")),
475 g1t_kit::now_ms(),
476 );
Usage, Billing settings and prepaid AI credit; fixes from the UX audit477 self.send(Method::Post, "/checkout/sessions", Some(form(&fields)), Some(&key)).await
Merge branch 'worktree-agent-a6a121745e81f639f'478 }
479
Merge Stripe Tax, the card fee on card payments, and one free workspace per person480 /// Starts a page on which a feature's monthly plan is paid for by card,
481 /// with the card fee as a monthly line of its own.
482 #[allow(clippy::too_many_arguments)]
Merge branch 'worktree-agent-a6a121745e81f639f'483 pub async fn start_subscription(
484 &self,
485 workspace: &str,
486 feature: &str,
487 title: &str,
488 monthly_cents: u32,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person489 fee_cents: u32,
Merge branch 'worktree-agent-a6a121745e81f639f'490 customer: Option<&str>,
491 return_url: &str,
492 ) -> Result<Session> {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person493 let fields = subscription_fields(workspace, feature, title, monthly_cents, fee_cents, customer, return_url);
494 let key = page_key(
495 "plan",
496 workspace,
497 &format!("{feature}/{monthly_cents}/{fee_cents}/{}", customer.unwrap_or("new")),
498 g1t_kit::now_ms(),
499 );
Usage, Billing settings and prepaid AI credit; fixes from the UX audit500 self.send(Method::Post, "/checkout/sessions", Some(form(&fields)), Some(&key)).await
Merge branch 'worktree-agent-a6a121745e81f639f'501 }
502
503 /// Starts a page that saves and verifies a card, with 3-D Secure asked
504 /// for wherever the card supports it. Nothing is charged: the card's
505 /// bank sees at most a $0 or $1 authorization that is never captured.
506 pub async fn start_card_check(&self, workspace: &str, customer: &str, return_url: &str) -> Result<Session> {
Usage, Billing settings and prepaid AI credit; fixes from the UX audit507 let fields = card_check_fields(workspace, customer, return_url);
508 let key = page_key("card_check", workspace, customer, g1t_kit::now_ms());
509 self.send(Method::Post, "/checkout/sessions", Some(form(&fields)), Some(&key)).await
510 }
511
512 /// The customer's default payment method: the one its invoices are
513 /// charged to, else its newest card. None when it has none.
514 pub async fn default_payment_method(&self, customer: &str) -> Result<Option<SavedMethod>> {
515 let found: serde_json::Value = self
516 .call(
517 Method::Get,
518 &format!("/customers/{}?expand[]=invoice_settings.default_payment_method", encode(customer)),
519 None,
520 )
521 .await?;
522 if let Some(method) = SavedMethod::from_json(&found["invoice_settings"]["default_payment_method"]) {
523 return Ok(Some(method));
524 }
525 let list: serde_json::Value = self
526 .call(Method::Get, &format!("/payment_methods?customer={}&type=card&limit=1", encode(customer)), None)
527 .await?;
528 Ok(list["data"].as_array().and_then(|data| data.first()).and_then(SavedMethod::from_json))
529 }
530
531 /// The customer as Stripe keeps it, with its tax ids.
532 pub async fn customer(&self, customer: &str) -> Result<serde_json::Value> {
533 self.call(Method::Get, &format!("/customers/{}?expand[]=tax_ids", encode(customer)), None).await
534 }
535
536 /// The customer's invoices, newest first.
537 pub async fn invoices(&self, customer: &str) -> Result<Vec<serde_json::Value>> {
538 let list: serde_json::Value = self.call(Method::Get, &format!("/invoices?customer={}&limit=24", encode(customer)), None).await?;
539 Ok(list["data"].as_array().cloned().unwrap_or_default())
Merge Stripe Tax, the card fee on card payments, and one free workspace per person540 }
541
542 /// Reverses a tax transaction in part for a refund: `refunded_cents` of
543 /// the payment, tax included, taken back across all of it.
544 pub async fn reverse_tax(&self, transaction: &str, reference: &str, refunded_cents: i64) -> Result<()> {
545 let _: serde_json::Value = self
546 .send(
547 Method::Post,
548 "/tax/transactions/create_reversal",
549 Some(form(&reversal_fields(transaction, reference, refunded_cents))),
550 Some(&format!("tax-reversal/{reference}")),
551 )
552 .await?;
553 Ok(())
554 }
555
556 /// Copies the billing address entered with a card onto the customer
557 /// when the customer has none Stripe Tax can use, so later invoices and
558 /// charges can be taxed. Never replaces an address an owner gave.
559 pub async fn fill_address(&self, customer: &str, address: &serde_json::Value) -> Result<bool> {
560 if !address_places_customer(address) {
561 return Ok(false);
562 }
563 let found = self.customer(customer).await?;
564 if address_places_customer(&found["address"]) {
565 return Ok(false);
566 }
567 let _: serde_json::Value = self.post(&format!("/customers/{}", encode(customer)), &address_fields(address)).await?;
568 Ok(true)
569 }
570
571 /// Whether the customer's address is enough for Stripe Tax.
572 pub async fn customer_placed(&self, customer: &str) -> Result<bool> {
573 let found = self.customer(customer).await?;
574 Ok(address_places_customer(&found["address"]) || address_places_customer(&found["shipping"]["address"]))
575 }
576}
577
578/// A customer's address from an address Stripe gave.
579pub(crate) fn address_fields(address: &serde_json::Value) -> Vec<(&'static str, String)> {
580 let text = |key: &str| address[key].as_str().unwrap_or_default().to_owned();
581 vec![
582 ("address[line1]", text("line1")),
583 ("address[line2]", text("line2")),
584 ("address[city]", text("city")),
585 ("address[state]", text("state")),
586 ("address[postal_code]", text("postal_code")),
587 ("address[country]", text("country")),
588 ]
589}
590
591/// A refund's tax reversal: a part of the payment, as a negative amount.
592pub(crate) fn reversal_fields(transaction: &str, reference: &str, refunded_cents: i64) -> Vec<(&'static str, String)> {
593 vec![
594 ("mode", "partial".to_owned()),
595 ("original_transaction", transaction.to_owned()),
596 ("reference", reference.to_owned()),
597 ("flat_amount", (-refunded_cents.abs()).to_string()),
598 ]
599}
600
Usage, Billing settings and prepaid AI credit; fixes from the UX audit601/// A saved way to pay, as far as it is safe to show.
602#[derive(Clone, Debug, PartialEq)]
603pub struct SavedMethod {
604 pub id: String,
605 pub kind: String,
606 pub brand: Option<String>,
607 pub last4: Option<String>,
608 pub exp_month: Option<u32>,
609 pub exp_year: Option<u32>,
610}
611
612impl SavedMethod {
613 fn from_json(method: &serde_json::Value) -> Option<SavedMethod> {
614 let id = method["id"].as_str()?.to_owned();
615 let kind = method["type"].as_str().unwrap_or("card").to_owned();
616 let details = &method[kind.as_str()];
617 Some(SavedMethod {
618 id,
619 brand: details["brand"].as_str().or_else(|| details["bank_name"].as_str()).map(str::to_owned),
620 last4: details["last4"].as_str().map(str::to_owned),
621 exp_month: details["exp_month"].as_u64().map(|m| m as u32),
622 exp_year: details["exp_year"].as_u64().map(|y| y as u32),
623 kind,
624 })
625 }
626}
627
628/// The idempotency key for a payment page: the same workspace, purpose and
629/// details within ten minutes is one page, however often it is asked for (a
630/// double click, two tabs).
631pub(crate) fn page_key(purpose: &str, workspace: &str, details: &str, now_ms: u64) -> String {
632 format!("page/{purpose}/{workspace}/{details}/{}", now_ms / 600_000)
633}
634
635/// Where Stripe sends the person back, with the page's id under `name`.
636fn back_to(return_url: &str, name: &str) -> String {
637 let separator = if return_url.contains('?') { '&' } else { '?' };
638 // Stripe fills in the page's id.
639 format!("{return_url}{separator}{name}={{CHECKOUT_SESSION_ID}}")
640}
641
Merge Stripe Tax, the card fee on card payments, and one free workspace per person642/// The name of the card fee's line everywhere it appears: Checkout pages,
643/// subscriptions and invoices. Revenue figures find it by this name.
644pub(crate) const CARD_FEE_LINE: &str = "Card processing fee";
645
646/// The form keys of one line on a payment page, for the first two lines
647/// (what is bought, and the card fee).
648const LINE_KEYS: [[&str; 8]; 2] = [
649 [
650 "line_items[0][quantity]",
651 "line_items[0][price_data][currency]",
652 "line_items[0][price_data][unit_amount]",
653 "line_items[0][price_data][tax_behavior]",
654 "line_items[0][price_data][product_data][name]",
655 "line_items[0][price_data][product_data][tax_code]",
656 "line_items[0][price_data][product_data][description]",
657 "line_items[0][price_data][recurring][interval]",
658 ],
659 [
660 "line_items[1][quantity]",
661 "line_items[1][price_data][currency]",
662 "line_items[1][price_data][unit_amount]",
663 "line_items[1][price_data][tax_behavior]",
664 "line_items[1][price_data][product_data][name]",
665 "line_items[1][price_data][product_data][tax_code]",
666 "line_items[1][price_data][product_data][description]",
667 "line_items[1][price_data][recurring][interval]",
668 ],
669];
670
671/// One line on a payment page at g1t's tax code, its price excluding tax;
672/// monthly when `monthly`.
673fn page_line(index: usize, cents: u32, name: String, description: Option<String>, monthly: bool) -> Vec<(&'static str, String)> {
674 let keys = LINE_KEYS[index.min(1)];
675 let mut fields = vec![
676 (keys[0], "1".to_owned()),
677 (keys[1], "usd".to_owned()),
678 (keys[2], cents.to_string()),
679 (keys[3], "exclusive".to_owned()),
680 (keys[4], name),
681 (keys[5], TAX_CODE.to_owned()),
682 ];
683 if let Some(description) = description {
684 fields.push((keys[6], description));
685 }
686 if monthly {
687 fields.push((keys[7], "month".to_owned()));
688 }
689 fields
690}
691
692/// The card fee's line on a payment page, when there is a fee.
693fn fee_line(fee_cents: u32, monthly: bool) -> Vec<(&'static str, String)> {
694 if fee_cents == 0 {
695 return vec![];
696 }
697 page_line(1, fee_cents, CARD_FEE_LINE.to_owned(), Some("Stripe's fee for taking the payment by card, passed on at cost".to_owned()), monthly)
698}
699
700/// A prepayment page's fields. By card, the card fee is its own line; by
701/// bank transfer there is none.
Usage, Billing settings and prepaid AI credit; fixes from the UX audit702pub(crate) fn prepay_fields(
703 workspace: &str,
704 amount_cents: u32,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person705 fee_cents: u32,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit706 customer: Option<&str>,
707 return_url: &str,
708 bank_transfer: bool,
709) -> Vec<(&'static str, String)> {
710 let mut fields = vec![("mode", "payment".to_owned())];
711 if bank_transfer {
712 fields.extend([
713 ("payment_method_types[0]", "customer_balance".to_owned()),
714 ("payment_method_options[customer_balance][funding_type]", "bank_transfer".to_owned()),
715 ("payment_method_options[customer_balance][bank_transfer][type]", "us_bank_transfer".to_owned()),
716 ]);
717 } else {
718 fields.extend([
Merge branch 'worktree-agent-a6a121745e81f639f'719 ("payment_method_types[0]", "card".to_owned()),
720 ("payment_method_options[card][request_three_d_secure]", "any".to_owned()),
Usage, Billing settings and prepaid AI credit; fixes from the UX audit721 ("payment_intent_data[setup_future_usage]", "off_session".to_owned()),
722 ]);
723 }
724 fields.extend([
725 ("success_url", back_to(return_url, "session")),
726 ("cancel_url", return_url.to_owned()),
727 ("client_reference_id", workspace.to_owned()),
728 ("metadata[workspace]", workspace.to_owned()),
729 ]);
Merge Stripe Tax, the card fee on card payments, and one free workspace per person730 fields.extend(page_line(0, amount_cents, format!("g1t usage paid in advance for {workspace}"), None, false));
731 if !bank_transfer {
732 fields.extend(fee_line(fee_cents, false));
733 }
734 fields.extend(checkout_tax_fields(customer));
Usage, Billing settings and prepaid AI credit; fixes from the UX audit735 match customer {
736 Some(customer) => fields.push(("customer", customer.to_owned())),
737 None => fields.push(("customer_creation", "always".to_owned())),
738 }
739 fields
740}
741
Merge Stripe Tax, the card fee on card payments, and one free workspace per person742/// A plan's page's fields: the plan, and the card fee as a monthly line of
743/// its own. In subscription mode Stripe makes the customer itself when
744/// there is none; `customer_creation` is for payment mode only. The
745/// subscription it starts keeps `automatic_tax`, so every renewal is taxed.
Usage, Billing settings and prepaid AI credit; fixes from the UX audit746pub(crate) fn subscription_fields(
747 workspace: &str,
748 feature: &str,
749 title: &str,
750 monthly_cents: u32,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person751 fee_cents: u32,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit752 customer: Option<&str>,
753 return_url: &str,
754) -> Vec<(&'static str, String)> {
755 let mut fields = vec![
756 ("mode", "subscription".to_owned()),
757 ("payment_method_types[0]", "card".to_owned()),
758 ("success_url", back_to(return_url, "session")),
759 ("cancel_url", return_url.to_owned()),
760 ("client_reference_id", workspace.to_owned()),
761 ("metadata[workspace]", workspace.to_owned()),
762 ("metadata[feature]", feature.to_owned()),
763 ("subscription_data[metadata][workspace]", workspace.to_owned()),
764 ("subscription_data[metadata][feature]", feature.to_owned()),
765 ];
Merge Stripe Tax, the card fee on card payments, and one free workspace per person766 fields.extend(page_line(0, monthly_cents, format!("g1t {title} for {workspace}"), None, true));
767 fields.extend(fee_line(fee_cents, true));
768 fields.extend(checkout_tax_fields(customer));
Usage, Billing settings and prepaid AI credit; fixes from the UX audit769 if let Some(customer) = customer {
770 fields.push(("customer", customer.to_owned()));
771 }
772 fields
773}
774
Merge Stripe Tax, the card fee on card payments, and one free workspace per person775/// A card check's page's fields: setup mode, nothing charged, so nothing
776/// to tax. Setup mode takes no line items and no amount. The billing
777/// address is asked for all the same, and copied onto the customer once
778/// the card is checked (`fill_address`), so the plan and invoices that
779/// follow can be taxed.
Usage, Billing settings and prepaid AI credit; fixes from the UX audit780pub(crate) fn card_check_fields(workspace: &str, customer: &str, return_url: &str) -> Vec<(&'static str, String)> {
781 vec![
782 ("mode", "setup".to_owned()),
783 ("customer", customer.to_owned()),
784 ("payment_method_types[0]", "card".to_owned()),
785 ("payment_method_options[card][request_three_d_secure]", "any".to_owned()),
Merge Stripe Tax, the card fee on card payments, and one free workspace per person786 ("billing_address_collection", "required".to_owned()),
Usage, Billing settings and prepaid AI credit; fixes from the UX audit787 ("success_url", back_to(return_url, "card_check")),
788 ("cancel_url", return_url.to_owned()),
789 ("client_reference_id", workspace.to_owned()),
790 ("metadata[workspace]", workspace.to_owned()),
791 ("metadata[purpose]", "card_check".to_owned()),
792 ("setup_intent_data[metadata][workspace]", workspace.to_owned()),
793 ("setup_intent_data[description]", format!("Card check for g1t workspace {workspace}; never charged")),
794 ]
795}
796
Merge Stripe Tax, the card fee on card payments, and one free workspace per person797/// A Stripe invoice's tax settings, for every invoice g1t makes: worked out
798/// by Stripe Tax.
799pub(crate) fn invoice_tax_fields() -> Vec<(&'static str, String)> {
800 vec![("automatic_tax[enabled]", "true".to_owned())]
801}
802
803/// An invoice item's tax settings: g1t's tax code, the amount excluding tax.
804pub(crate) fn item_tax_fields() -> Vec<(&'static str, String)> {
805 vec![("tax_behavior", "exclusive".to_owned()), ("tax_code", TAX_CODE.to_owned())]
806}
807
808/// What a paid Stripe invoice comes to, as billing counts it: the tax
809/// (`tax`, in this API version), the card fee (its lines, by name), and
810/// what is left for what was sold, all in cents.
811#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
812pub(crate) struct InvoiceSplit {
813 pub tax_cents: i64,
814 pub fee_cents: i64,
815 pub net_cents: i64,
816}
817
818/// A plan started on a saved card, as its subscription needs it.
819pub(crate) struct SavedPlan<'a> {
820 pub workspace: &'a str,
821 pub feature: &'a str,
822 pub title: &'a str,
823 pub monthly_cents: u32,
824 pub fee_cents: u32,
825 pub customer: &'a str,
826 pub payment_method: &'a str,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit827}
828
Merge Stripe Tax, the card fee on card payments, and one free workspace per person829/// A subscription's fields, started on a saved card: the plan, the card
830/// fee as a monthly item when there is one, each excluding tax, and Stripe
831/// Tax on.
832pub(crate) fn saved_subscription_fields(p: &SavedPlan<'_>, product: &str, fee_product: Option<&str>) -> Vec<(&'static str, String)> {
833 let mut fields = vec![
834 ("customer", p.customer.to_owned()),
835 ("default_payment_method", p.payment_method.to_owned()),
836 ("payment_behavior", "error_if_incomplete".to_owned()),
837 ("automatic_tax[enabled]", "true".to_owned()),
838 ("items[0][price_data][currency]", "usd".to_owned()),
839 ("items[0][price_data][product]", product.to_owned()),
840 ("items[0][price_data][unit_amount]", p.monthly_cents.to_string()),
841 ("items[0][price_data][recurring][interval]", "month".to_owned()),
842 ("items[0][price_data][tax_behavior]", "exclusive".to_owned()),
843 ("metadata[workspace]", p.workspace.to_owned()),
844 ("metadata[feature]", p.feature.to_owned()),
845 ("description", format!("{} plan for {}", p.title, p.workspace)),
846 ];
847 if let (Some(fee_product), true) = (fee_product, p.fee_cents > 0) {
848 fields.extend([
849 ("items[1][price_data][currency]", "usd".to_owned()),
850 ("items[1][price_data][product]", fee_product.to_owned()),
851 ("items[1][price_data][unit_amount]", p.fee_cents.to_string()),
852 ("items[1][price_data][recurring][interval]", "month".to_owned()),
853 ("items[1][price_data][tax_behavior]", "exclusive".to_owned()),
854 ]);
855 }
856 fields
857}
858
859pub(crate) fn invoice_split(invoice: &serde_json::Value, amount_paid: i64) -> InvoiceSplit {
860 let tax_cents = invoice["tax"].as_i64().unwrap_or(0).max(0);
861 let fee_cents: i64 = invoice["lines"]["data"]
862 .as_array()
863 .map(|lines| {
864 lines
865 .iter()
866 .filter(|line| line["description"].as_str().is_some_and(|d| d.contains(CARD_FEE_LINE)))
867 .map(|line| line["amount"].as_i64().unwrap_or(0))
868 .sum()
869 })
870 .unwrap_or(0);
871 let fee_cents = fee_cents.max(0);
872 InvoiceSplit { tax_cents, fee_cents, net_cents: (amount_paid - tax_cents - fee_cents).max(0) }
873}
874
Usage, Billing settings and prepaid AI credit; fixes from the UX audit875impl Stripe {
Merge branch 'worktree-agent-a6a121745e81f639f'876 /// What a card check's setup found, once it succeeded.
877 pub async fn checked_card(&self, setup_intent: &str) -> Result<Option<CheckedCard>> {
878 #[derive(Deserialize)]
879 struct Setup {
880 status: String,
881 payment_method: Option<String>,
882 }
883 #[derive(Deserialize)]
884 struct Card {
885 fingerprint: Option<String>,
886 brand: Option<String>,
887 last4: Option<String>,
888 funding: Option<String>,
889 country: Option<String>,
890 }
891 #[derive(Deserialize)]
Merge Stripe Tax, the card fee on card payments, and one free workspace per person892 struct Billing {
893 #[serde(default)]
894 address: Option<serde_json::Value>,
895 }
896 #[derive(Deserialize)]
Merge branch 'worktree-agent-a6a121745e81f639f'897 struct PaymentMethod {
898 card: Option<Card>,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person899 #[serde(default)]
900 billing_details: Option<Billing>,
Merge branch 'worktree-agent-a6a121745e81f639f'901 }
902 let setup: Setup = self.call(Method::Get, &format!("/setup_intents/{}", encode(setup_intent)), None).await?;
903 let (true, Some(method)) = (setup.status == "succeeded", setup.payment_method) else { return Ok(None) };
904 let found: PaymentMethod = self.call(Method::Get, &format!("/payment_methods/{}", encode(&method)), None).await?;
905 let card = found.card;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person906 let address = found.billing_details.and_then(|b| b.address).filter(|a| a.is_object());
Merge branch 'worktree-agent-a6a121745e81f639f'907 Ok(Some(CheckedCard {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person908 address,
Merge branch 'worktree-agent-a6a121745e81f639f'909 payment_method: method,
910 fingerprint: card.as_ref().and_then(|c| c.fingerprint.clone()),
911 brand: card.as_ref().and_then(|c| c.brand.clone()),
912 last4: card.as_ref().and_then(|c| c.last4.clone()),
913 funding: card.as_ref().and_then(|c| c.funding.clone()),
914 country: card.as_ref().and_then(|c| c.country.clone()),
915 }))
916 }
917
918 /// Makes `payment_method` the card the customer's invoices are charged to.
919 pub async fn set_default_card(&self, customer: &str, payment_method: &str) -> Result<()> {
920 let _: serde_json::Value = self
921 .call(
922 Method::Post,
923 &format!("/customers/{}", encode(customer)),
924 Some(form(&[("invoice_settings[default_payment_method]", payment_method.to_owned())])),
925 )
926 .await?;
927 Ok(())
928 }
929
Merge Stripe Tax, the card fee on card payments, and one free workspace per person930 /// One of g1t's products at Stripe, tagged `metadata[g1t]` (the plan's
931 /// is `plan`, the card fee's `card_fee`), made the first time it is
932 /// needed, with g1t's tax code. One made before Stripe Tax was on is
933 /// given the code when it is found.
934 async fn product(&self, tag: &str, name: &str) -> Result<String> {
Merge branch 'worktree-agent-a6a121745e81f639f'935 #[derive(Deserialize)]
936 struct Product {
937 id: String,
938 #[serde(default)]
939 metadata: Option<std::collections::HashMap<String, String>>,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person940 #[serde(default)]
941 tax_code: Option<serde_json::Value>,
Merge branch 'worktree-agent-a6a121745e81f639f'942 }
943 #[derive(Deserialize)]
944 struct List {
945 data: Vec<Product>,
946 }
947 let list: List = self.call(Method::Get, "/products?active=true&limit=100", None).await?;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person948 let ours = |p: &Product| p.metadata.as_ref().and_then(|m| m.get("g1t")).map(String::as_str) == Some(tag);
Merge branch 'worktree-agent-a6a121745e81f639f'949 if let Some(found) = list.data.into_iter().find(ours) {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person950 let coded = found.tax_code.as_ref().is_some_and(|code| code.as_str() == Some(TAX_CODE) || code["id"].as_str() == Some(TAX_CODE));
951 if !coded {
952 let _: serde_json::Value = self
953 .call(Method::Post, &format!("/products/{}", encode(&found.id)), Some(form(&[("tax_code", TAX_CODE.to_owned())])))
954 .await?;
955 }
Merge branch 'worktree-agent-a6a121745e81f639f'956 return Ok(found.id);
957 }
958 let created: Product = self
959 .call(
960 Method::Post,
961 "/products",
Merge Stripe Tax, the card fee on card payments, and one free workspace per person962 Some(form(&[("name", name.to_owned()), ("metadata[g1t]", tag.to_owned()), ("tax_code", TAX_CODE.to_owned())])),
Merge branch 'worktree-agent-a6a121745e81f639f'963 )
964 .await?;
965 Ok(created.id)
966 }
967
Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how.968 /// Archives one of g1t's products (by its `metadata[g1t]` tag), so it
969 /// is never sold again; past invoices keep pointing at it. Nothing to
970 /// do when there is no such active product.
971 pub async fn archive_product(&self, tag: &str) -> Result<()> {
972 #[derive(Deserialize)]
973 struct Product {
974 id: String,
975 #[serde(default)]
976 metadata: Option<std::collections::HashMap<String, String>>,
977 }
978 #[derive(Deserialize)]
979 struct List {
980 data: Vec<Product>,
981 }
982 let list: List = self.call(Method::Get, "/products?active=true&limit=100", None).await?;
983 for product in list.data.into_iter().filter(|p| p.metadata.as_ref().and_then(|m| m.get("g1t")).map(String::as_str) == Some(tag)) {
984 let _: serde_json::Value = self
985 .call(Method::Post, &format!("/products/{}", encode(&product.id)), Some(form(&[("active", "false".to_owned())])))
986 .await?;
987 }
988 Ok(())
989 }
990
Merge Stripe Tax, the card fee on card payments, and one free workspace per person991 /// Starts the monthly plan on a saved card, at once, with tax worked
992 /// out by Stripe Tax on every invoice and the card fee as a monthly item
993 /// of its own. Fails rather than leaving it half-started when the card's
994 /// bank wants the person again, or when Stripe Tax cannot place the
995 /// customer (no billing address yet); the caller then sends them to
996 /// Stripe's page, which asks for the address.
997 #[allow(clippy::too_many_arguments)]
Merge branch 'worktree-agent-a6a121745e81f639f'998 pub async fn subscribe_with_card(
999 &self,
1000 workspace: &str,
1001 feature: &str,
1002 title: &str,
1003 monthly_cents: u32,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1004 fee_cents: u32,
Merge branch 'worktree-agent-a6a121745e81f639f'1005 customer: &str,
1006 payment_method: &str,
1007 ) -> Result<StripeSubscription> {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1008 let product = self.product(feature, &format!("{title} plan")).await?;
1009 let fee_product = if fee_cents > 0 { Some(self.product("card_fee", CARD_FEE_LINE).await?) } else { None };
1010 let plan = SavedPlan { workspace, feature, title, monthly_cents, fee_cents, customer, payment_method };
1011 let fields = saved_subscription_fields(&plan, &product, fee_product.as_deref());
1012 let key = plan_key(workspace, feature, &format!("{payment_method}/{monthly_cents}/{fee_cents}"), g1t_kit::now_ms());
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1013 self.send(Method::Post, "/subscriptions", Some(form(&fields)), Some(&key)).await
Merge branch 'worktree-agent-a6a121745e81f639f'1014 }
1015
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1016
Merge branch 'worktree-agent-a6a121745e81f639f'1017 /// Ends a subscription now: one that never started properly.
1018 pub async fn cancel_now(&self, id: &str) -> Result<StripeSubscription> {
1019 self.call(Method::Delete, &format!("/subscriptions/{}", encode(id)), None).await
1020 }
1021
1022 pub async fn subscription(&self, id: &str) -> Result<StripeSubscription> {
1023 self.call(Method::Get, &format!("/subscriptions/{}", encode(id)), None)
1024 .await
1025 }
1026
1027 /// Ends a plan when its period does (`cancel` true), or takes that back.
1028 pub async fn cancel_at_period_end(&self, id: &str, cancel: bool) -> Result<StripeSubscription> {
1029 self.call(
1030 Method::Post,
1031 &format!("/subscriptions/{}", encode(id)),
1032 Some(form(&[("cancel_at_period_end", cancel.to_string())])),
1033 )
1034 .await
1035 }
1036
1037 pub async fn session(&self, id: &str) -> Result<Session> {
1038 self.call(
1039 Method::Get,
1040 &format!("/checkout/sessions/{}", encode(id)),
1041 None,
1042 )
1043 .await
1044 }
1045}
1046
1047/// Whether the processor said an id it was given does not exist, as when
1048/// g1t moves to another Stripe account and ids saved from the old one stay
1049/// behind.
1050pub(crate) fn is_missing(error: &Error) -> bool {
1051 error.to_string().contains("resource_missing")
1052}
1053
1054pub(crate) fn is_live(key: &str) -> bool {
1055 key.starts_with("sk_live_") || key.starts_with("rk_live_")
1056}
1057
1058#[cfg(test)]
Billing: ask Stripe for tax only where Stripe Tax is active1059mod tax_mode_tests {
1060 use super::*;
1061
1062 #[test]
1063 fn untaxed_requests_lose_only_automatic_tax() {
1064 let body = form(&[("mode", "payment".to_owned()), ("automatic_tax[enabled]", "true".to_owned()), ("tax_id_collection[enabled]", "true".to_owned())]);
1065 assert_eq!(without_automatic_tax(&body), form(&[("mode", "payment".to_owned()), ("tax_id_collection[enabled]", "true".to_owned())]));
1066 assert!(taxed_path("/checkout/sessions") && taxed_path("/invoices") && taxed_path("/subscriptions"));
1067 assert!(!taxed_path("/invoices/in_1/finalize") && !taxed_path("/payment_intents"));
1068 }
1069}
1070
1071#[cfg(test)]
Merge branch 'worktree-agent-a6a121745e81f639f'1072mod tests {
1073 use super::*;
1074
1075 #[test]
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging1076 fn asking_twice_for_a_plan_starts_one() {
1077 let at = 1_791_000_000_000;
1078 assert_eq!(plan_key("acme", "plan", "pm_1", at), plan_key("acme", "plan", "pm_1", at + 1_000));
1079 assert_ne!(plan_key("acme", "plan", "pm_1", at), plan_key("acme", "plan", "pm_2", at));
1080 assert_ne!(plan_key("acme", "plan", "pm_1", at), plan_key("other", "plan", "pm_1", at));
1081 }
1082
1083 #[test]
Merge branch 'worktree-agent-a6a121745e81f639f'1084 fn form_values_are_percent_encoded() {
1085 assert_eq!(
1086 form(&[
1087 (
1088 "success_url",
1089 "https://g1t.sh/a/-/billing?session={ID}".to_owned()
1090 ),
1091 ("line_items[0][quantity]", "1".to_owned()),
1092 ]),
1093 "success_url=https%3A%2F%2Fg1t.sh%2Fa%2F-%2Fbilling%3Fsession%3D%7BID%7D&line_items%5B0%5D%5Bquantity%5D=1"
1094 );
1095 }
1096
1097 #[test]
1098 fn test_keys_are_not_live() {
1099 assert!(is_live("sk_live_abc"));
1100 assert!(!is_live("sk_test_abc"));
1101 assert!(!is_live(""));
1102 }
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1103
1104 fn has(fields: &[(&str, String)], name: &str) -> Option<String> {
1105 fields.iter().find(|(n, _)| *n == name).map(|(_, v)| v.clone())
1106 }
1107
1108 #[test]
1109 fn every_request_names_the_api_version_it_is_written_for() {
1110 // Without it a new Stripe account answers at its newest version, and
1111 // invoices, charges and pages read differently from what billing
1112 // expects.
1113 assert!(STRIPE_VERSION.starts_with("2025-02-24"));
1114 let source = include_str!("stripe.rs");
1115 assert!(source.contains(concat!("headers.set(\"stripe-", "version\", STRIPE_VERSION)")));
1116 }
1117
1118 #[test]
1119 fn the_plans_page_is_a_subscription_without_payment_mode_fields() {
1120 let url = "https://g1t.sh/acme/-/billing?plan=plan";
1121 for customer in [None, Some("cus_1")] {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1122 let fields = subscription_fields("acme", "plan", "g1t", 2_000, 92, customer, url);
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1123 assert_eq!(has(&fields, "mode").as_deref(), Some("subscription"));
1124 // customer_creation is for payment mode; Stripe refuses it here.
1125 assert!(has(&fields, "customer_creation").is_none());
1126 assert!(has(&fields, "payment_intent_data[setup_future_usage]").is_none());
1127 assert_eq!(has(&fields, "customer").as_deref(), customer);
1128 assert_eq!(has(&fields, "success_url").unwrap(), "https://g1t.sh/acme/-/billing?plan=plan&session={CHECKOUT_SESSION_ID}");
1129 assert_eq!(has(&fields, "line_items[0][price_data][recurring][interval]").as_deref(), Some("month"));
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1130 // The card fee is a monthly line of its own.
1131 assert_eq!(has(&fields, "line_items[1][price_data][unit_amount]").as_deref(), Some("92"));
1132 assert_eq!(has(&fields, "line_items[1][price_data][recurring][interval]").as_deref(), Some("month"));
1133 assert_eq!(has(&fields, "line_items[1][price_data][product_data][name]").as_deref(), Some(CARD_FEE_LINE));
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1134 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1135 let fields = subscription_fields("acme", "plan", "g1t", 2_000, 0, None, url);
1136 assert!(has(&fields, "line_items[1][quantity]").is_none());
1137 }
1138
1139 /// Every field a page must carry for Stripe Tax: tax on top of the
1140 /// price, an address always asked for, the buyer's tax ID, and each
1141 /// line at g1t's tax code, excluding tax. With a customer, what is
1142 /// entered is saved on it (Checkout refuses `automatic_tax` and
1143 /// `tax_id_collection` for a customer otherwise).
1144 fn assert_taxed_page(fields: &[(&str, String)], customer: Option<&str>, lines: usize) {
1145 assert_eq!(has(fields, "automatic_tax[enabled]").as_deref(), Some("true"));
1146 assert_eq!(has(fields, "billing_address_collection").as_deref(), Some("required"));
1147 assert_eq!(has(fields, "tax_id_collection[enabled]").as_deref(), Some("true"));
1148 let saved = if customer.is_some() { Some("auto") } else { None };
1149 assert_eq!(has(fields, "customer_update[address]").as_deref(), saved);
1150 assert_eq!(has(fields, "customer_update[name]").as_deref(), saved);
1151 for (line, keys) in LINE_KEYS.iter().enumerate().take(lines) {
1152 assert_eq!(has(fields, keys[3]).as_deref(), Some("exclusive"), "line {line}");
1153 assert_eq!(has(fields, keys[5]).as_deref(), Some(TAX_CODE), "line {line}");
1154 }
1155 // No key twice: Stripe takes the last, silently.
1156 let mut names: Vec<&str> = fields.iter().map(|(n, _)| *n).collect();
1157 names.sort_unstable();
1158 let count = names.len();
1159 names.dedup();
1160 assert_eq!(names.len(), count);
1161 }
1162
1163 #[test]
1164 fn every_payment_page_is_taxed_by_stripe_tax() {
1165 let url = "https://g1t.sh/acme/-/billing";
1166 for customer in [None, Some("cus_1")] {
1167 // The plan and Security and quality.
1168 assert_taxed_page(&subscription_fields("acme", "plan", "g1t", 2_000, 92, customer, url), customer, 2);
1169 assert_taxed_page(&subscription_fields("acme", "security", "Security and quality", 1_000, 61, customer, url), customer, 2);
1170 // Prepaying by card, with its fee, and by bank transfer, without.
1171 assert_taxed_page(&prepay_fields("acme", 5_000, 185, customer, url, false), customer, 2);
1172 assert_taxed_page(&prepay_fields("acme", 100_000, 0, customer, url, true), customer, 1);
1173 }
1174 // tax_id_collection and customer_update are for payment and
1175 // subscription mode; a card check charges nothing, so it is not
1176 // taxed, but it asks for the address the plan will be taxed at.
1177 let check = card_check_fields("acme", "cus_1", url);
1178 assert!(has(&check, "automatic_tax[enabled]").is_none());
1179 assert_eq!(has(&check, "billing_address_collection").as_deref(), Some("required"));
1180 }
1181
1182 #[test]
1183 fn a_bank_transfer_has_no_card_fee() {
1184 let fields = prepay_fields("acme", 100_000, 3_100, Some("cus_1"), "https://g1t.sh/acme/-/billing", true);
1185 assert!(has(&fields, "line_items[1][quantity]").is_none());
1186 let fields = prepay_fields("acme", 5_000, 185, None, "https://g1t.sh/acme/-/billing", false);
1187 assert_eq!(has(&fields, "line_items[1][price_data][unit_amount]").as_deref(), Some("185"));
1188 assert_eq!(has(&fields, "line_items[1][price_data][product_data][name]").as_deref(), Some(CARD_FEE_LINE));
1189 }
1190
1191 #[test]
1192 fn a_plan_on_a_saved_card_is_taxed_with_its_card_fee() {
1193 let plan = SavedPlan { workspace: "acme", feature: "plan", title: "g1t", monthly_cents: 2_000, fee_cents: 92, customer: "cus_1", payment_method: "pm_1" };
1194 let fields = saved_subscription_fields(&plan, "prod_plan", Some("prod_fee"));
1195 assert_eq!(has(&fields, "automatic_tax[enabled]").as_deref(), Some("true"));
1196 assert_eq!(has(&fields, "items[0][price_data][tax_behavior]").as_deref(), Some("exclusive"));
1197 assert_eq!(has(&fields, "items[1][price_data][product]").as_deref(), Some("prod_fee"));
1198 assert_eq!(has(&fields, "items[1][price_data][unit_amount]").as_deref(), Some("92"));
1199 assert_eq!(has(&fields, "items[1][price_data][tax_behavior]").as_deref(), Some("exclusive"));
1200 let fields = saved_subscription_fields(&SavedPlan { fee_cents: 0, ..plan }, "prod_plan", None);
1201 assert!(has(&fields, "items[1][price_data][product]").is_none());
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1202 }
1203
1204 #[test]
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1205 fn invoices_and_their_lines_are_taxed() {
1206 assert_eq!(has(&invoice_tax_fields(), "automatic_tax[enabled]").as_deref(), Some("true"));
1207 let item = item_tax_fields();
1208 assert_eq!(has(&item, "tax_behavior").as_deref(), Some("exclusive"));
1209 assert_eq!(has(&item, "tax_code").as_deref(), Some(TAX_CODE));
1210 }
1211
1212 #[test]
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.1213 fn a_refund_reverses_part_of_its_tax() {
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1214 let reversal = reversal_fields("tax_1", "pi_1/re_1", 500);
1215 assert_eq!(has(&reversal, "flat_amount").as_deref(), Some("-500"));
1216 assert_eq!(has(&reversal, "mode").as_deref(), Some("partial"));
1217 }
1218
1219 #[test]
1220 fn a_paid_invoice_splits_into_tax_card_fee_and_what_was_sold() {
1221 let invoice = serde_json::json!({
1222 "tax": 180,
1223 "lines": { "data": [
1224 { "description": "1 × g1t plan (at $20.00 / month)", "amount": 2000 },
1225 { "description": "1 × Card processing fee (at $0.92 / month)", "amount": 92 },
1226 ] }
1227 });
1228 assert_eq!(invoice_split(&invoice, 2_272), InvoiceSplit { tax_cents: 180, fee_cents: 92, net_cents: 2_000 });
1229 // Before Stripe Tax: no tax, no fee, all of it sold.
1230 assert_eq!(invoice_split(&serde_json::json!({ "tax": null }), 2_000).net_cents, 2_000);
1231 // A page's tax and what came before it.
1232 let session: Session = serde_json::from_value(serde_json::json!({
1233 "id": "cs_1", "payment_status": "paid", "amount_total": 5_585, "amount_subtotal": 5_185,
1234 "total_details": { "amount_tax": 400 }, "customer": "cus_1", "payment_intent": "pi_1"
1235 }))
1236 .unwrap();
1237 assert_eq!((session.tax_cents(), session.before_tax_cents()), (400, 5_185));
1238 }
1239
1240 #[test]
1241 fn an_address_places_a_customer_for_tax() {
1242 use serde_json::json;
1243 assert!(address_places_customer(&json!({ "country": "DE" })));
1244 assert!(address_places_customer(&json!({ "country": "US", "postal_code": "94107" })));
1245 assert!(!address_places_customer(&json!({ "country": "US", "postal_code": "" })));
1246 assert!(address_places_customer(&json!({ "country": "CA", "state": "ON" })));
1247 assert!(!address_places_customer(&json!({ "country": "" })));
1248 assert!(!address_places_customer(&serde_json::Value::Null));
1249 let error = Error::RustError(r#"the card processor answered 400: {"error":{"code":"customer_tax_location_invalid","message":"x"}}"#.into());
1250 assert!(is_tax_location_error(&error));
1251 }
1252
1253 #[test]
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1254 fn a_card_check_is_a_setup_page_with_no_amount() {
1255 let fields = card_check_fields("acme", "cus_1", "https://g1t.sh/acme/-/billing");
1256 assert_eq!(has(&fields, "mode").as_deref(), Some("setup"));
1257 assert!(fields.iter().all(|(n, _)| !n.starts_with("line_items")));
1258 assert_eq!(has(&fields, "success_url").unwrap(), "https://g1t.sh/acme/-/billing?card_check={CHECKOUT_SESSION_ID}");
1259 assert_eq!(has(&fields, "customer").as_deref(), Some("cus_1"));
1260 }
1261
1262 #[test]
1263 fn a_page_asked_for_twice_is_one_page() {
1264 let at = 1_791_000_000_000;
1265 assert_eq!(page_key("plan", "acme", "plan/2000", at), page_key("plan", "acme", "plan/2000", at + 60_000));
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.1266 assert_ne!(page_key("plan", "acme", "plan/2000", at), page_key("prepay", "acme", "plan/2000", at));
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1267 }
1268
1269 #[test]
1270 fn a_stripe_failure_reads_as_a_sentence_never_raw() {
1271 let refused = Error::RustError(
1272 r#"the card processor answered 400: {"error":{"message":"You may only specify one of these parameters: customer, customer_creation.","type":"invalid_request_error"}}"#.into(),
1273 );
1274 assert_eq!(friendly(&refused), "Stripe refused it: You may only specify one of these parameters: customer, customer_creation.");
1275 assert!(!friendly(&refused).contains("invalid_request_error"));
1276 let odd = Error::RustError("the card processor answered 502: <html>".into());
1277 assert!(friendly(&odd).starts_with("Stripe refused it. Try again"));
1278 let down = Error::RustError("network connection lost".into());
1279 assert_eq!(friendly(&down), "Stripe could not be reached. Try again in a minute.");
1280 }
Merge branch 'worktree-agent-a6a121745e81f639f'1281}

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