Files
Ely-GPUI-Components/src/account/payment.rs
T
ZacharyZhang-NY a2f654044e Account · Billing and team: a meter's name gives way, a month past 12 says so
A Meter's name takes the free width and ends in an ellipsis while its
count stays whole, so a long quota name keeps "9 of 12 seats" inside a
280px box. `expiry` says why it refuses a date, and Expires reads
"There is no month 13." apart from "This date has passed.". Tests now
cover an American Express card's fifteen digits and four-digit code,
the address emptying once an invitation goes, a quota past its limit,
and a prompt with benefits standing as a paywall and one without as a
line.
2026-09-27 14:05:34 -04:00

346 lines
11 KiB
Rust

use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div,
};
use jiff::Timestamp;
use super::login::{Run, field};
use crate::{
buttons::{Button, ButtonVariant},
feedback::InlineMessage,
forms::{Enter, FormField, Input, MaskedInput},
primitives::{Icon, IconName, Severity},
theme::{ActiveTheme, IconSize},
typography::format::system_zone,
};
/// A card network, told by a number's first digits.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CardBrand {
Visa,
Mastercard,
Amex,
Discover,
Other,
}
impl CardBrand {
pub fn of(digits: &str) -> Self {
let lead = |n: usize| digits.get(..n).and_then(|lead| lead.parse::<u32>().ok());
if digits.starts_with('4') {
CardBrand::Visa
} else if lead(2).is_some_and(|lead| (51..=55).contains(&lead))
|| lead(4).is_some_and(|lead| (2221..=2720).contains(&lead))
{
CardBrand::Mastercard
} else if lead(2).is_some_and(|lead| lead == 34 || lead == 37) {
CardBrand::Amex
} else if digits.starts_with("6011") || digits.starts_with("65") {
CardBrand::Discover
} else {
CardBrand::Other
}
}
pub fn name(self) -> &'static str {
match self {
CardBrand::Visa => "Visa",
CardBrand::Mastercard => "Mastercard",
CardBrand::Amex => "American Express",
CardBrand::Discover => "Discover",
CardBrand::Other => "Card",
}
}
/// The number's pattern and its digits, and the digits of its security code.
fn shape(self) -> (&'static str, usize, usize) {
match self {
CardBrand::Amex => ("9999 999999 99999", 15, 4),
_ => ("9999 9999 9999 9999", 16, 3),
}
}
}
/// Whether `digits` pass the Luhn check that card numbers carry.
pub fn luhn(digits: &str) -> bool {
let mut sum = 0;
for (ix, ch) in digits.chars().rev().enumerate() {
let Some(mut digit) = ch.to_digit(10) else {
return false;
};
if ix % 2 == 1 {
digit *= 2;
if digit > 9 {
digit -= 9;
}
}
sum += digit;
}
!digits.is_empty() && sum % 10 == 0
}
/// Why an expiry cannot be used.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExpiryError {
/// Not written "MM/YY".
Unread,
NoMonth(i8),
Passed,
}
/// The month and year of an expiry written "MM/YY", when it reads and has not passed `now`, a year and month.
pub fn expiry(text: &str, now: (i32, i8)) -> Result<(i8, i32), ExpiryError> {
let (month, year) = text.split_once('/').ok_or(ExpiryError::Unread)?;
let month = month
.trim()
.parse::<i8>()
.map_err(|_| ExpiryError::Unread)?;
let year = 2000
+ year
.trim()
.parse::<i32>()
.map_err(|_| ExpiryError::Unread)?;
if !(1..=12).contains(&month) {
return Err(ExpiryError::NoMonth(month));
}
if (year, month) < now {
return Err(ExpiryError::Passed);
}
Ok((month, year))
}
/// What Expires says under a whole date it refuses.
fn expiry_fault(text: &str, now: (i32, i8)) -> Option<String> {
if text.len() != "MM/YY".len() {
return None;
}
match expiry(text, now) {
Err(ExpiryError::NoMonth(month)) => Some(format!("There is no month {month:02}.")),
Err(ExpiryError::Passed) => Some("This date has passed.".into()),
Err(ExpiryError::Unread) | Ok(_) => None,
}
}
/// A card as its form hands it over.
#[derive(Clone, Debug, PartialEq)]
pub struct CardDetails {
pub brand: CardBrand,
pub number: SharedString,
pub month: i8,
pub year: i32,
pub cvc: SharedString,
pub name: SharedString,
}
type OnCard = Rc<dyn Fn(&CardDetails, &mut Window, &mut App)>;
fn digits(text: &str) -> String {
text.chars().filter(char::is_ascii_digit).collect()
}
/// A card to pay with: its number, spaced as its network writes it and named once told, the month and year it runs out, its security code and the name on it. Save waits for a number that checks out, a month ahead, a full code and a name.
#[derive(IntoElement)]
pub struct PaymentMethodForm {
id: ElementId,
error: Option<SharedString>,
busy: bool,
on_submit: Option<OnCard>,
}
impl PaymentMethodForm {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
error: None,
busy: false,
on_submit: None,
}
}
/// Why the card was refused, shown above Save.
pub fn error(mut self, error: impl Into<SharedString>) -> Self {
self.error = Some(error.into());
self
}
/// While the owner checks the card.
pub fn busy(mut self, busy: bool) -> Self {
self.busy = busy;
self
}
pub fn on_submit(
mut self,
handler: impl Fn(&CardDetails, &mut Window, &mut App) + 'static,
) -> Self {
self.on_submit = Some(Rc::new(handler));
self
}
}
impl RenderOnce for PaymentMethodForm {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let on_submit = self
.on_submit
.unwrap_or_else(|| panic!("payment method form {id:?} has no on_submit"));
let number = field(&id, "number", "", false, window, cx);
let until = field(&id, "expiry", "", false, window, cx);
let code = field(&id, "cvc", "", false, window, cx);
let name = field(&id, "name", "As printed on the card", false, window, cx);
let typed = digits(number.read(cx).text());
let brand = CardBrand::of(&typed);
let (mask, length, code_length) = brand.shape();
let complete = typed.len() == length;
let checks = complete && luhn(&typed);
let now = Timestamp::now().to_zoned(system_zone("payment method form"));
let today = (now.year() as i32, now.month());
let expires = expiry(until.read(cx).text(), today);
let cvc = digits(code.read(cx).text());
let holder: SharedString = name.read(cx).text().trim().to_string().into();
let card = expires
.ok()
.filter(|_| checks && cvc.len() == code_length && !holder.is_empty())
.map(|(month, year)| CardDetails {
brand,
number: typed.clone().into(),
month,
year,
cvc: cvc.clone().into(),
name: holder,
});
let ready = card.is_some() && !self.busy;
let submit: Run = Rc::new(move |window, cx| {
let card = card.as_ref().expect("Save rests until the card reads");
log::info!("payment method form: save {}", card.brand.name());
on_submit(card, window, cx);
});
let enter = submit.clone();
let theme = cx.theme();
let mut number_field = FormField::new((id.clone(), "number-field"), "Card number");
if complete && !checks {
number_field = number_field.error("This number does not check out.");
} else if brand != CardBrand::Other {
number_field = number_field.description(brand.name());
}
let mut until_field = FormField::new((id.clone(), "expiry-field"), "Expires");
if let Some(fault) = expiry_fault(until.read(cx).text(), today) {
until_field = until_field.error(fault);
}
div()
.flex()
.flex_col()
.gap_4()
.capture_action(move |_: &Enter, window, cx| {
if ready {
cx.stop_propagation();
enter(window, cx);
}
})
.child(
number_field.child(
MaskedInput::new((id.clone(), "number"), &number, mask).prefix(
Icon::new(IconName::CreditCard)
.size(IconSize::Sm)
.color(theme.colors.fg_subtle),
),
),
)
.child(
div()
.flex()
.flex_wrap()
.gap_3()
.child(div().flex_1().min_w(theme.label_width() * 0.6).child(
until_field.child(MaskedInput::new(
(id.clone(), "expiry"),
&until,
"99/99",
)),
))
.child(div().flex_1().min_w(theme.label_width() * 0.6).child(
FormField::new((id.clone(), "cvc-field"), "Security code").child(
MaskedInput::new(
(id.clone(), "cvc"),
&code,
if code_length == 4 { "9999" } else { "999" },
),
),
)),
)
.child(
FormField::new((id.clone(), "name-field"), "Name on card").child(Input::new(&name)),
)
.children(
self.error
.map(|error| InlineMessage::new(Severity::Danger, error)),
)
.child(
Button::new((id.clone(), "save"), "Save card")
.variant(ButtonVariant::Primary)
.full_width()
.loading(self.busy)
.disabled(!ready)
.on_click(move |_, window, cx| submit(window, cx)),
)
}
}
#[cfg(test)]
mod tests {
use super::{CardBrand, ExpiryError, expiry, expiry_fault, luhn};
#[test]
fn a_number_tells_its_network_and_checks_out() {
assert_eq!(CardBrand::of("4242"), CardBrand::Visa);
assert_eq!(CardBrand::of("5555"), CardBrand::Mastercard);
assert_eq!(CardBrand::of("2221"), CardBrand::Mastercard);
assert_eq!(CardBrand::of("3782"), CardBrand::Amex);
assert_eq!(CardBrand::of("6011"), CardBrand::Discover);
assert_eq!(CardBrand::of("9"), CardBrand::Other);
assert!(luhn("4242424242424242"));
assert!(luhn("378282246310005"));
assert!(!luhn("4242424242424241"));
assert!(!luhn(""));
}
#[test]
fn an_expiry_reads_and_has_not_passed() {
let now = (2026, 9);
assert_eq!(
expiry("09/26", now),
Ok((9, 2026)),
"this month still counts"
);
assert_eq!(expiry("12/31", now), Ok((12, 2031)));
assert_eq!(expiry("08/26", now), Err(ExpiryError::Passed));
assert_eq!(expiry("13/30", now), Err(ExpiryError::NoMonth(13)));
assert_eq!(expiry("1230", now), Err(ExpiryError::Unread));
}
#[test]
fn a_whole_expiry_says_why_it_is_refused() {
let now = (2026, 9);
assert_eq!(
expiry_fault("13/30", now).as_deref(),
Some("There is no month 13.")
);
assert_eq!(
expiry_fault("00/30", now).as_deref(),
Some("There is no month 00.")
);
assert_eq!(
expiry_fault("08/26", now).as_deref(),
Some("This date has passed.")
);
assert_eq!(expiry_fault("09/26", now), None);
assert_eq!(
expiry_fault("13/3", now),
None,
"a date still being typed says nothing"
);
}
}