From 0af6c4b0b6981a2be610ff462d07636ff2635613 Mon Sep 17 00:00:00 2001 From: John Preston Date: Mon, 29 Mar 2021 16:16:54 +0400 Subject: [PATCH] Add local validation for card information. --- Telegram/CMakeLists.txt | 2 - .../SourceFiles/boxes/change_phone_box.cpp | 2 +- .../SourceFiles/boxes/confirm_phone_box.cpp | 8 - .../SourceFiles/boxes/confirm_phone_box.h | 1 - Telegram/SourceFiles/config.h | 3 - .../passport/passport_panel_edit_contact.cpp | 3 +- .../payments/payments_checkout_process.cpp | 39 ++- .../SourceFiles/payments/payments_form.cpp | 121 ++++++-- Telegram/SourceFiles/payments/payments_form.h | 11 +- .../SourceFiles/payments/stripe/stripe_card.h | 1 + .../payments/stripe/stripe_card_validator.cpp | 279 ++++++++++++++++++ .../payments/stripe/stripe_card_validator.h | 51 ++++ .../payments/ui/payments_edit_card.cpp | 211 ++++++++++++- .../payments/ui/payments_edit_information.cpp | 33 +-- .../payments/ui/payments_field.cpp | 156 +++++++++- .../SourceFiles/payments/ui/payments_field.h | 69 ++++- .../payments/ui/payments_form_summary.cpp | 4 +- .../payments/ui/payments_panel_data.h | 3 +- .../ui/boxes/country_select_box.cpp | 16 +- Telegram/SourceFiles/ui/special_fields.cpp | 26 +- Telegram/SourceFiles/ui/special_fields.h | 2 + Telegram/cmake/lib_stripe.cmake | 2 + Telegram/cmake/td_ui.cmake | 5 +- 23 files changed, 950 insertions(+), 98 deletions(-) create mode 100644 Telegram/SourceFiles/payments/stripe/stripe_card_validator.cpp create mode 100644 Telegram/SourceFiles/payments/stripe/stripe_card_validator.h diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 9f5defbd55..a183df1c46 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -1029,8 +1029,6 @@ PRIVATE ui/search_field_controller.h ui/special_buttons.cpp ui/special_buttons.h - ui/special_fields.cpp - ui/special_fields.h ui/unread_badge.cpp ui/unread_badge.h window/main_window.cpp diff --git a/Telegram/SourceFiles/boxes/change_phone_box.cpp b/Telegram/SourceFiles/boxes/change_phone_box.cpp index 727c8b93c8..199ebeab53 100644 --- a/Telegram/SourceFiles/boxes/change_phone_box.cpp +++ b/Telegram/SourceFiles/boxes/change_phone_box.cpp @@ -151,7 +151,7 @@ void ChangePhoneBox::EnterPhone::prepare() { this, st::defaultInputField, tr::lng_change_phone_new_title(), - ExtractPhonePrefix(_session->user()->phone()), + Ui::ExtractPhonePrefix(_session->user()->phone()), phoneValue); _phone->resize(st::boxWidth - 2 * st::boxPadding.left(), _phone->height()); diff --git a/Telegram/SourceFiles/boxes/confirm_phone_box.cpp b/Telegram/SourceFiles/boxes/confirm_phone_box.cpp index e0d4bf803b..3d960f6683 100644 --- a/Telegram/SourceFiles/boxes/confirm_phone_box.cpp +++ b/Telegram/SourceFiles/boxes/confirm_phone_box.cpp @@ -71,14 +71,6 @@ void ShowPhoneBannedError(const QString &phone) { [=] { SendToBannedHelp(phone); close(); })); } -QString ExtractPhonePrefix(const QString &phone) { - const auto pattern = phoneNumberParse(phone); - if (!pattern.isEmpty()) { - return phone.mid(0, pattern[0]); - } - return QString(); -} - SentCodeField::SentCodeField( QWidget *parent, const style::InputField &st, diff --git a/Telegram/SourceFiles/boxes/confirm_phone_box.h b/Telegram/SourceFiles/boxes/confirm_phone_box.h index cb3b15de8f..5448a78f82 100644 --- a/Telegram/SourceFiles/boxes/confirm_phone_box.h +++ b/Telegram/SourceFiles/boxes/confirm_phone_box.h @@ -22,7 +22,6 @@ class Session; } // namespace Main void ShowPhoneBannedError(const QString &phone); -[[nodiscard]] QString ExtractPhonePrefix(const QString &phone); class SentCodeField : public Ui::InputField { public: diff --git a/Telegram/SourceFiles/config.h b/Telegram/SourceFiles/config.h index 57a8ac904f..978089c507 100644 --- a/Telegram/SourceFiles/config.h +++ b/Telegram/SourceFiles/config.h @@ -13,9 +13,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL enum { MaxSelectedItems = 100, - MaxPhoneCodeLength = 4, // max length of country phone code - MaxPhoneTailLength = 32, // rest of the phone number, without country code (seen 12 at least), need more for service numbers - LocalEncryptIterCount = 4000, // key derivation iteration count LocalEncryptNoPwdIterCount = 4, // key derivation iteration count without pwd (not secure anyway) LocalEncryptSaltSize = 32, // 256 bit diff --git a/Telegram/SourceFiles/passport/passport_panel_edit_contact.cpp b/Telegram/SourceFiles/passport/passport_panel_edit_contact.cpp index 3b9986aded..59ca85cd91 100644 --- a/Telegram/SourceFiles/passport/passport_panel_edit_contact.cpp +++ b/Telegram/SourceFiles/passport/passport_panel_edit_contact.cpp @@ -278,7 +278,8 @@ void PanelEditContact::setupControls( wrap.data(), fieldStyle, std::move(fieldPlaceholder), - ExtractPhonePrefix(_controller->bot()->session().user()->phone()), + Ui::ExtractPhonePrefix( + _controller->bot()->session().user()->phone()), data); } else { _field = Ui::CreateChild( diff --git a/Telegram/SourceFiles/payments/payments_checkout_process.cpp b/Telegram/SourceFiles/payments/payments_checkout_process.cpp index aba6d5e5a2..7d67fb94ef 100644 --- a/Telegram/SourceFiles/payments/payments_checkout_process.cpp +++ b/Telegram/SourceFiles/payments/payments_checkout_process.cpp @@ -166,23 +166,36 @@ void CheckoutProcess::handleError(const Error &error) { showForm(); return; } - using Field = Ui::InformationField; + using InfoField = Ui::InformationField; + using CardField = Ui::CardField; if (id == u"REQ_INFO_NAME_INVALID"_q) { - showInformationError(Field::Name); + showInformationError(InfoField::Name); } else if (id == u"REQ_INFO_EMAIL_INVALID"_q) { - showInformationError(Field::Email); + showInformationError(InfoField::Email); } else if (id == u"REQ_INFO_PHONE_INVALID"_q) { - showInformationError(Field::Phone); + showInformationError(InfoField::Phone); } else if (id == u"ADDRESS_STREET_LINE1_INVALID"_q) { - showInformationError(Field::ShippingStreet); + showInformationError(InfoField::ShippingStreet); } else if (id == u"ADDRESS_CITY_INVALID"_q) { - showInformationError(Field::ShippingCity); + showInformationError(InfoField::ShippingCity); } else if (id == u"ADDRESS_STATE_INVALID"_q) { - showInformationError(Field::ShippingState); + showInformationError(InfoField::ShippingState); } else if (id == u"ADDRESS_COUNTRY_INVALID"_q) { - showInformationError(Field::ShippingCountry); + showInformationError(InfoField::ShippingCountry); } else if (id == u"ADDRESS_POSTCODE_INVALID"_q) { - showInformationError(Field::ShippingPostcode); + showInformationError(InfoField::ShippingPostcode); + } else if (id == u"LOCAL_CARD_NUMBER_INVALID"_q) { + showCardError(CardField::Number); + } else if (id == u"LOCAL_CARD_EXPIRE_DATE_INVALID"_q) { + showCardError(CardField::ExpireDate); + } else if (id == u"LOCAL_CARD_CVC_INVALID"_q) { + showCardError(CardField::Cvc); + } else if (id == u"LOCAL_CARD_HOLDER_NAME_INVALID"_q) { + showCardError(CardField::Name); + } else if (id == u"LOCAL_CARD_BILLING_COUNTRY_INVALID"_q) { + showCardError(CardField::AddressCountry); + } else if (id == u"LOCAL_CARD_BILLING_ZIP_INVALID"_q) { + showCardError(CardField::AddressZip); } else if (id == u"SHIPPING_BOT_TIMEOUT"_q) { showToast({ "Error: Bot Timeout!" }); // #TODO payments errors message } else if (id == u"SHIPPING_NOT_AVAILABLE"_q) { @@ -196,11 +209,17 @@ void CheckoutProcess::handleError(const Error &error) { if (id == u"InvalidNumber"_q || id == u"IncorrectNumber"_q) { showCardError(Field::Number); } else if (id == u"InvalidCVC"_q || id == u"IncorrectCVC"_q) { - showCardError(Field::CVC); + showCardError(Field::Cvc); } else if (id == u"InvalidExpiryMonth"_q || id == u"InvalidExpiryYear"_q || id == u"ExpiredCard"_q) { showCardError(Field::ExpireDate); + } else if (id == u"CardDeclined"_q) { + // #TODO payments errors message + showToast({ "Error: " + id }); + } else if (id == u"ProcessingError"_q) { + // #TODO payments errors message + showToast({ "Error: " + id }); } else { showToast({ "Error: " + id }); } diff --git a/Telegram/SourceFiles/payments/payments_form.cpp b/Telegram/SourceFiles/payments/payments_form.cpp index 702d4d5f13..96dfa27c1a 100644 --- a/Telegram/SourceFiles/payments/payments_form.cpp +++ b/Telegram/SourceFiles/payments/payments_form.cpp @@ -19,6 +19,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "stripe/stripe_api_client.h" #include "stripe/stripe_error.h" #include "stripe/stripe_token.h" +#include "stripe/stripe_card_validator.h" #include "ui/image/image.h" #include "apiwrap.h" #include "styles/style_payments.h" // paymentsThumbnailSize. @@ -270,6 +271,7 @@ void Form::processDetails(const MTPDpayments_paymentForm &data) { void Form::processSavedInformation(const MTPDpaymentRequestedInfo &data) { const auto address = data.vshipping_address(); _savedInformation = Ui::RequestedInformation{ + .defaultPhone = defaultPhone(), .defaultCountry = defaultCountry(), .name = qs(data.vname().value_or_empty()), .phone = qs(data.vphone().value_or_empty()), @@ -293,11 +295,16 @@ void Form::refreshPaymentMethodDetails() { const auto &entered = _paymentMethod.newCredentials; _paymentMethod.ui.title = entered ? entered.title : saved.title; _paymentMethod.ui.ready = entered || saved; + _paymentMethod.ui.native.defaultPhone = defaultPhone(); _paymentMethod.ui.native.defaultCountry = defaultCountry(); } +QString Form::defaultPhone() const { + return _session->user()->phone(); +} + QString Form::defaultCountry() const { - return Data::CountryISO2ByPhone(_session->user()->phone()); + return Data::CountryISO2ByPhone(defaultPhone()); } void Form::fillPaymentMethodInformation() { @@ -382,10 +389,16 @@ void Form::validateInformation(const Ui::RequestedInformation &information) { _api.request(base::take(_validateRequestId)).cancel(); } _validatedInformation = information; - if (const auto error = localInformationError(information)) { - _updates.fire_copy(error); + if (!validateInformationLocal(information)) { return; } + + Assert(!_invoice.isShippingAddressRequested + || information.shippingAddress); + Assert(!_invoice.isNameRequested || !information.name.isEmpty()); + Assert(!_invoice.isEmailRequested || !information.email.isEmpty()); + Assert(!_invoice.isPhoneRequested || !information.phone.isEmpty()); + _validateRequestId = _api.request(MTPpayments_ValidateRequestedInfo( MTP_flags(0), // #TODO payments save information MTP_int(_msgId.msg), @@ -415,26 +428,43 @@ void Form::validateInformation(const Ui::RequestedInformation &information) { }).send(); } -Error Form::localInformationError( +bool Form::validateInformationLocal( const Ui::RequestedInformation &information) const { - const auto error = [](const QString &id) { - return Error{ Error::Type::Validate, id }; + if (const auto error = informationErrorLocal(information)) { + _updates.fire_copy(error); + return false; + } + return true; +} + +Error Form::informationErrorLocal( + const Ui::RequestedInformation &information) const { + auto errors = QStringList(); + const auto push = [&](const QString &id) { + errors.push_back(id); }; - if (_invoice.isShippingAddressRequested - && !information.shippingAddress) { - return information.shippingAddress.address1.isEmpty() - ? error(u"ADDRESS_STREET_LINE1_INVALID"_q) - : information.shippingAddress.city.isEmpty() - ? error(u"ADDRESS_CITY_INVALID"_q) - : information.shippingAddress.countryIso2.isEmpty() - ? error(u"ADDRESS_COUNTRY_INVALID"_q) - : (Unexpected("Shipping Address error."), Error()); - } else if (_invoice.isNameRequested && information.name.isEmpty()) { - return error(u"REQ_INFO_NAME_INVALID"_q); - } else if (_invoice.isEmailRequested && information.email.isEmpty()) { - return error(u"REQ_INFO_EMAIL_INVALID"_q); - } else if (_invoice.isPhoneRequested && information.phone.isEmpty()) { - return error(u"REQ_INFO_PHONE_INVALID"_q); + if (_invoice.isShippingAddressRequested) { + if (information.shippingAddress.address1.isEmpty()) { + push(u"ADDRESS_STREET_LINE1_INVALID"_q); + } + if (information.shippingAddress.city.isEmpty()) { + push(u"ADDRESS_CITY_INVALID"_q); + } + if (information.shippingAddress.countryIso2.isEmpty()) { + push(u"ADDRESS_COUNTRY_INVALID"_q); + } + } + if (_invoice.isNameRequested && information.name.isEmpty()) { + push(u"REQ_INFO_NAME_INVALID"_q); + } + if (_invoice.isEmailRequested && information.email.isEmpty()) { + push(u"REQ_INFO_EMAIL_INVALID"_q); + } + if (_invoice.isPhoneRequested && information.phone.isEmpty()) { + push(u"REQ_INFO_PHONE_INVALID"_q); + } + if (!errors.isEmpty()) { + return Error{ Error::Type::Validate, errors.front() }; } return Error(); } @@ -442,6 +472,9 @@ Error Form::localInformationError( void Form::validateCard(const Ui::UncheckedCardDetails &details) { Expects(!v::is_null(_paymentMethod.native.data)); + if (!validateCardLocal(details)) { + return; + } const auto &native = _paymentMethod.native.data; if (const auto stripe = std::get_if(&native)) { validateCard(*stripe, details); @@ -450,6 +483,52 @@ void Form::validateCard(const Ui::UncheckedCardDetails &details) { } } +bool Form::validateCardLocal(const Ui::UncheckedCardDetails &details) const { + if (auto error = cardErrorLocal(details)) { + _updates.fire(std::move(error)); + return false; + } + return true; +} + +Error Form::cardErrorLocal(const Ui::UncheckedCardDetails &details) const { + using namespace Stripe; + + auto errors = QStringList(); + const auto push = [&](const QString &id) { + errors.push_back(id); + }; + const auto kValid = ValidationState::Valid; + if (ValidateCard(details.number).state != kValid) { + push(u"LOCAL_CARD_NUMBER_INVALID"_q); + } + if (ValidateParsedExpireDate( + details.expireMonth, + details.expireYear + ) != kValid) { + push(u"LOCAL_CARD_EXPIRE_DATE_INVALID"_q); + } + if (ValidateCvc(details.number, details.cvc).state != kValid) { + push(u"LOCAL_CARD_CVC_INVALID"_q); + } + if (_paymentMethod.ui.native.needCardholderName + && details.cardholderName.isEmpty()) { + push(u"LOCAL_CARD_HOLDER_NAME_INVALID"_q); + } + if (_paymentMethod.ui.native.needCountry + && details.addressCountry.isEmpty()) { + push(u"LOCAL_CARD_BILLING_COUNTRY_INVALID"_q); + } + if (_paymentMethod.ui.native.needZip + && details.addressZip.isEmpty()) { + push(u"LOCAL_CARD_BILLING_ZIP_INVALID"_q); + } + if (!errors.isEmpty()) { + return Error{ Error::Type::Validate, errors.front() }; + } + return Error(); +} + void Form::validateCard( const StripePaymentMethod &method, const Ui::UncheckedCardDetails &details) { diff --git a/Telegram/SourceFiles/payments/payments_form.h b/Telegram/SourceFiles/payments/payments_form.h index 367512e2bd..c964a66836 100644 --- a/Telegram/SourceFiles/payments/payments_form.h +++ b/Telegram/SourceFiles/payments/payments_form.h @@ -196,14 +196,23 @@ private: void fillPaymentMethodInformation(); void fillStripeNativeMethod(); void refreshPaymentMethodDetails(); + [[nodiscard]] QString defaultPhone() const; [[nodiscard]] QString defaultCountry() const; void validateCard( const StripePaymentMethod &method, const Ui::UncheckedCardDetails &details); - [[nodiscard]] Error localInformationError( + bool validateInformationLocal( const Ui::RequestedInformation &information) const; + [[nodiscard]] Error informationErrorLocal( + const Ui::RequestedInformation &information) const; + + bool validateCardLocal( + const Ui::UncheckedCardDetails &details) const; + [[nodiscard]] Error cardErrorLocal( + const Ui::UncheckedCardDetails &details) const; + const not_null _session; MTP::Sender _api; diff --git a/Telegram/SourceFiles/payments/stripe/stripe_card.h b/Telegram/SourceFiles/payments/stripe/stripe_card.h index 0b90592628..30ff47b643 100644 --- a/Telegram/SourceFiles/payments/stripe/stripe_card.h +++ b/Telegram/SourceFiles/payments/stripe/stripe_card.h @@ -20,6 +20,7 @@ enum class CardBrand { Discover, JCB, DinersClub, + UnionPay, Unknown, }; diff --git a/Telegram/SourceFiles/payments/stripe/stripe_card_validator.cpp b/Telegram/SourceFiles/payments/stripe/stripe_card_validator.cpp new file mode 100644 index 0000000000..aa2482c092 --- /dev/null +++ b/Telegram/SourceFiles/payments/stripe/stripe_card_validator.cpp @@ -0,0 +1,279 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "stripe/stripe_card_validator.h" + +#include + +namespace Stripe { +namespace { + +constexpr auto kMinCvcLength = 3; + +struct BinRange { + QString low; + QString high; + int length = 0; + CardBrand brand = CardBrand::Unknown; +}; + +[[nodiscard]] const std::vector &AllRanges() { + static auto kResult = std::vector{ + // Unknown + { "", "", 19, CardBrand::Unknown }, + // American Express + { "34", "34", 15, CardBrand::Amex }, + { "37", "37", 15, CardBrand::Amex }, + // Diners Club + { "30", "30", 16, CardBrand::DinersClub }, + { "36", "36", 14, CardBrand::DinersClub }, + { "38", "39", 16, CardBrand::DinersClub }, + // Discover + { "60", "60", 16, CardBrand::Discover }, + { "64", "65", 16, CardBrand::Discover }, + // JCB + { "35", "35", 16, CardBrand::JCB }, + // Mastercard + { "50", "59", 16, CardBrand::MasterCard }, + { "22", "27", 16, CardBrand::MasterCard }, + { "67", "67", 16, CardBrand::MasterCard }, // Maestro + // UnionPay + { "62", "62", 16, CardBrand::UnionPay }, + { "81", "81", 16, CardBrand::UnionPay }, + // Visa + { "40", "49", 16, CardBrand::Visa }, + { "413600", "413600", 13, CardBrand::Visa }, + { "444509", "444509", 13, CardBrand::Visa }, + { "444509", "444509", 13, CardBrand::Visa }, + { "444550", "444550", 13, CardBrand::Visa }, + { "450603", "450603", 13, CardBrand::Visa }, + { "450617", "450617", 13, CardBrand::Visa }, + { "450628", "450629", 13, CardBrand::Visa }, + { "450636", "450636", 13, CardBrand::Visa }, + { "450640", "450641", 13, CardBrand::Visa }, + { "450662", "450662", 13, CardBrand::Visa }, + { "463100", "463100", 13, CardBrand::Visa }, + { "476142", "476142", 13, CardBrand::Visa }, + { "476143", "476143", 13, CardBrand::Visa }, + { "492901", "492902", 13, CardBrand::Visa }, + { "492920", "492920", 13, CardBrand::Visa }, + { "492923", "492923", 13, CardBrand::Visa }, + { "492928", "492930", 13, CardBrand::Visa }, + { "492937", "492937", 13, CardBrand::Visa }, + { "492939", "492939", 13, CardBrand::Visa }, + { "492960", "492960", 13, CardBrand::Visa }, + }; + return kResult; +} + +[[nodiscard]] bool BinRangeMatchesNumber( + const BinRange &range, + const QString &sanitized) { + const auto minWithLow = std::min(sanitized.size(), range.low.size()); + if (sanitized.midRef(0, minWithLow).toInt() + < range.low.midRef(0, minWithLow).toInt()) { + return false; + } + const auto minWithHigh = std::min(sanitized.size(), range.high.size()); + if (sanitized.midRef(0, minWithHigh).toInt() + > range.high.midRef(0, minWithHigh).toInt()) { + return false; + } + return true; +} + +[[nodiscard]] bool IsNumeric(const QString &value) { + return QRegularExpression("^[0-9]*$").match(value).hasMatch(); +} + +[[nodiscard]] QString RemoveWhitespaces(QString value) { + return value.replace(QRegularExpression("\\s"), QString()); +} + +[[nodiscard]] std::vector BinRangesForNumber( + const QString &sanitized) { + const auto &all = AllRanges(); + auto result = std::vector(); + result.reserve(all.size()); + for (const auto &range : all) { + if (BinRangeMatchesNumber(range, sanitized)) { + result.push_back(range); + } + } + return result; +} + +[[nodiscard]] BinRange MostSpecificBinRangeForNumber( + const QString &sanitized) { + auto possible = BinRangesForNumber(sanitized); + const auto compare = [&](const BinRange &a, const BinRange &b) { + if (sanitized.isEmpty()) { + const auto aUnknown = (a.brand == CardBrand::Unknown); + const auto bUnknown = (b.brand == CardBrand::Unknown); + if (aUnknown && !bUnknown) { + return true; + } else if (!aUnknown && bUnknown) { + return false; + } + } + return a.low.size() < b.low.size(); + }; + std::sort(begin(possible), end(possible), compare); + return possible.back(); +} + +[[nodiscard]] int MaxCvcLengthForBranch(CardBrand brand) { + switch (brand) { + case CardBrand::Amex: + case CardBrand::Unknown: + return 4; + default: + return 3; + } +} + +[[nodiscard]] std::vector PossibleBrandsForNumber( + const QString &sanitized) { + const auto ranges = BinRangesForNumber(sanitized); + auto result = std::vector(); + for (const auto &range : ranges) { + const auto brand = range.brand; + if (brand == CardBrand::Unknown + || (std::find(begin(result), end(result), brand) + != end(result))) { + continue; + } + result.push_back(brand); + } + return result; +} + +[[nodiscard]] CardBrand BrandForNumber(const QString &number) { + const auto sanitized = RemoveWhitespaces(number); + if (!IsNumeric(sanitized)) { + return CardBrand::Unknown; + } + const auto possible = PossibleBrandsForNumber(sanitized); + return (possible.size() == 1) ? possible.front() : CardBrand::Unknown; +} + +[[nodiscard]] bool IsValidLuhn(const QString &sanitized) { + auto odd = true; + auto sum = 0; + for (auto i = sanitized.end(); i != sanitized.begin();) { + --i; + auto digit = int(i->unicode() - '0'); + odd = !odd; + if (odd) { + digit *= 2; + } + if (digit > 9) { + digit -= 9; + } + sum += digit; + } + return (sum % 10) == 0; +} + +} // namespace + +CardValidationResult ValidateCard(const QString &number) { + const auto sanitized = RemoveWhitespaces(number); + if (!IsNumeric(sanitized)) { + return { .state = ValidationState::Invalid }; + } else if (sanitized.isEmpty()) { + return { .state = ValidationState::Incomplete }; + } + const auto range = MostSpecificBinRangeForNumber(sanitized); + const auto brand = range.brand; + if (sanitized.size() > range.length) { + return { .state = ValidationState::Invalid, .brand = brand }; + } else if (sanitized.size() < range.length) { + return { .state = ValidationState::Incomplete, .brand = brand }; + } else if (!IsValidLuhn(sanitized)) { + return { .state = ValidationState::Invalid, .brand = brand }; + } + return { + .state = ValidationState::Valid, + .brand = brand, + .finished = true, + }; +} + +ExpireDateValidationResult ValidateExpireDate(const QString &date) { + const auto sanitized = RemoveWhitespaces(date).replace('/', QString()); + if (!IsNumeric(sanitized)) { + return { ValidationState::Invalid }; + } else if (sanitized.size() < 2) { + return { ValidationState::Incomplete }; + } + const auto normalized = (sanitized[0] > '1' ? "0" : "") + sanitized; + const auto month = normalized.mid(0, 2).toInt(); + if (month < 1 || month > 12) { + return { ValidationState::Invalid }; + } else if (normalized.size() < 4) { + return { ValidationState::Incomplete }; + } else if (normalized.size() > 4) { + return { ValidationState::Invalid }; + } + const auto year = 2000 + normalized.mid(2).toInt(); + + const auto currentDate = QDate::currentDate(); + const auto currentMonth = currentDate.month(); + const auto currentYear = currentDate.year(); + if (year < currentYear) { + return { ValidationState::Invalid }; + } else if (year == currentYear && month < currentMonth) { + return { ValidationState::Invalid }; + } + return { ValidationState::Valid, true }; +} + +ValidationState ValidateParsedExpireDate( + quint32 month, + quint32 year) { + if ((year / 100) != 20) { + return ValidationState::Invalid; + } + return ValidateExpireDate( + QString("%1%2" + ).arg(month, 2, 10, QChar('0') + ).arg(year % 100, 2, 10, QChar('0')) + ).state; +} + +CvcValidationResult ValidateCvc( + const QString &number, + const QString &cvc) { + if (!IsNumeric(cvc)) { + return { ValidationState::Invalid }; + } else if (cvc.size() < kMinCvcLength) { + return { ValidationState::Incomplete }; + } + const auto maxLength = MaxCvcLengthForBranch(BrandForNumber(number)); + if (cvc.size() > maxLength) { + return { ValidationState::Invalid }; + } + return { ValidationState::Valid, (cvc.size() == maxLength) }; +} + +std::vector CardNumberFormat(const QString &number) { + static const auto kDefault = std::vector{ 4, 4, 4, 4 }; + const auto sanitized = RemoveWhitespaces(number); + if (!IsNumeric(sanitized)) { + return kDefault; + } + const auto range = MostSpecificBinRangeForNumber(sanitized); + if (range.brand == CardBrand::DinersClub && range.length == 14) { + return { 4, 6, 4 }; + } else if (range.brand == CardBrand::Amex) { + return { 4, 6, 5 }; + } + return kDefault; +} + +} // namespace Stripe diff --git a/Telegram/SourceFiles/payments/stripe/stripe_card_validator.h b/Telegram/SourceFiles/payments/stripe/stripe_card_validator.h new file mode 100644 index 0000000000..417d4d9588 --- /dev/null +++ b/Telegram/SourceFiles/payments/stripe/stripe_card_validator.h @@ -0,0 +1,51 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "stripe/stripe_card.h" + +namespace Stripe { + +enum class ValidationState { + Invalid, + Incomplete, + Valid, +}; + +struct CardValidationResult { + ValidationState state = ValidationState::Invalid; + CardBrand brand = CardBrand::Unknown; + bool finished = false; +}; + +[[nodiscard]] CardValidationResult ValidateCard(const QString &number); + +struct ExpireDateValidationResult { + ValidationState state = ValidationState::Invalid; + bool finished = false; +}; + +[[nodiscard]] ExpireDateValidationResult ValidateExpireDate( + const QString &date); + +[[nodiscard]] ValidationState ValidateParsedExpireDate( + quint32 month, + quint32 year); + +struct CvcValidationResult { + ValidationState state = ValidationState::Invalid; + bool finished = false; +}; + +[[nodiscard]] CvcValidationResult ValidateCvc( + const QString &number, + const QString &cvc); + +[[nodiscard]] std::vector CardNumberFormat(const QString &number); + +} // namespace Stripe diff --git a/Telegram/SourceFiles/payments/ui/payments_edit_card.cpp b/Telegram/SourceFiles/payments/ui/payments_edit_card.cpp index 0d25d49957..583e73d107 100644 --- a/Telegram/SourceFiles/payments/ui/payments_edit_card.cpp +++ b/Telegram/SourceFiles/payments/ui/payments_edit_card.cpp @@ -9,6 +9,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "payments/ui/payments_panel_delegate.h" #include "payments/ui/payments_field.h" +#include "stripe/stripe_card_validator.h" #include "ui/widgets/scroll_area.h" #include "ui/widgets/buttons.h" #include "ui/widgets/labels.h" @@ -18,10 +19,15 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "styles/style_payments.h" #include "styles/style_passport.h" +#include + namespace Payments::Ui { namespace { -constexpr auto kMaxPostcodeSize = 10; +struct SimpleFieldState { + QString value; + int position = 0; +}; [[nodiscard]] uint32 ExtractYear(const QString &value) { return value.split('/').value(1).toInt() + 2000; @@ -31,6 +37,165 @@ constexpr auto kMaxPostcodeSize = 10; return value.split('/').value(0).toInt(); } +[[nodiscard]] QString RemoveNonNumbers(QString value) { + return value.replace(QRegularExpression("[^0-9]"), QString()); +} + +[[nodiscard]] SimpleFieldState NumbersOnlyState(SimpleFieldState state) { + return { + .value = RemoveNonNumbers(state.value), + .position = RemoveNonNumbers( + state.value.mid(0, state.position)).size(), + }; +} + +[[nodiscard]] SimpleFieldState PostprocessCardValidateResult( + SimpleFieldState result) { + const auto groups = Stripe::CardNumberFormat(result.value); + auto position = 0; + for (const auto length : groups) { + position += length; + if (position >= result.value.size()) { + break; + } + result.value.insert(position, QChar(' ')); + if (result.position >= position) { + ++result.position; + } + ++position; + } + return result; +} + +[[nodiscard]] SimpleFieldState PostprocessExpireDateValidateResult( + SimpleFieldState result) { + if (result.value.isEmpty()) { + return result; + } else if (result.value[0] == '1' && result.value[1] > '2') { + result.value = result.value.mid(0, 2); + return result; + } else if (result.value[0] > '1') { + result.value = '0' + result.value; + ++result.position; + } + if (result.value.size() > 1) { + if (result.value.size() > 4) { + result.value = result.value.mid(0, 4); + } + result.value.insert(2, '/'); + if (result.position >= 2) { + ++result.position; + } + } + return result; +} + +[[nodiscard]] bool IsBackspace(const FieldValidateRequest &request) { + return (request.wasAnchor == request.wasPosition) + && (request.wasPosition == request.nowPosition + 1) + && (request.wasValue.midRef(0, request.wasPosition - 1) + == request.nowValue.midRef(0, request.nowPosition)) + && (request.wasValue.midRef(request.wasPosition) + == request.nowValue.midRef(request.nowPosition)); +} + +[[nodiscard]] bool IsDelete(const FieldValidateRequest &request) { + return (request.wasAnchor == request.wasPosition) + && (request.wasPosition == request.nowPosition) + && (request.wasValue.midRef(0, request.wasPosition) + == request.nowValue.midRef(0, request.nowPosition)) + && (request.wasValue.midRef(request.wasPosition + 1) + == request.nowValue.midRef(request.nowPosition)); +} + +template < + typename ValueValidator, + typename ValueValidateResult = decltype( + std::declval()(QString()))> +[[nodiscard]] auto ComplexNumberValidator( + ValueValidator valueValidator, + Fn postprocess) { + using namespace Stripe; + return [=](FieldValidateRequest request) { + const auto realNowState = [&] { + const auto backspaced = IsBackspace(request); + const auto deleted = IsDelete(request); + if (!backspaced && !deleted) { + return NumbersOnlyState({ + .value = request.nowValue, + .position = request.nowPosition, + }); + } + const auto realWasState = NumbersOnlyState({ + .value = request.wasValue, + .position = request.wasPosition, + }); + const auto changedValue = deleted + ? (realWasState.value.mid(0, realWasState.position) + + realWasState.value.mid(realWasState.position + 1)) + : (realWasState.position > 1) + ? (realWasState.value.mid(0, realWasState.position - 1) + + realWasState.value.mid(realWasState.position)) + : realWasState.value.mid(realWasState.position); + return SimpleFieldState{ + .value = changedValue, + .position = (deleted + ? realWasState.position + : std::max(realWasState.position - 1, 0)) + }; + }(); + const auto result = valueValidator(realNowState.value); + const auto postprocessed = postprocess(realNowState); + return FieldValidateResult{ + .value = postprocessed.value, + .position = postprocessed.position, + .invalid = (result.state == ValidationState::Invalid), + .finished = result.finished, + }; + }; + +} + +[[nodiscard]] auto CardNumberValidator() { + return ComplexNumberValidator( + Stripe::ValidateCard, + PostprocessCardValidateResult); +} + +[[nodiscard]] auto ExpireDateValidator() { + return ComplexNumberValidator( + Stripe::ValidateExpireDate, + PostprocessExpireDateValidateResult); +} + +[[nodiscard]] auto CvcValidator(Fn number) { + using namespace Stripe; + return [=](FieldValidateRequest request) { + const auto realNowState = NumbersOnlyState({ + .value = request.nowValue, + .position = request.nowPosition, + }); + const auto result = ValidateCvc(number(), realNowState.value); + + return FieldValidateResult{ + .value = realNowState.value, + .position = realNowState.position, + .invalid = (result.state == ValidationState::Invalid), + .finished = result.finished, + }; + }; +} + +[[nodiscard]] auto CardHolderNameValidator() { + return [=](FieldValidateRequest request) { + return FieldValidateResult{ + .value = request.nowValue.toUpper(), + .position = request.nowPosition, + .invalid = request.nowValue.isEmpty(), + }; + }; +} + } // namespace EditCard::EditCard( @@ -111,15 +276,8 @@ not_null EditCard::setupContent() { _number = add({ .type = FieldType::CardNumber, .placeholder = tr::lng_payments_card_number(), - .required = true, + .validator = CardNumberValidator(), }); - if (_native.needCardholderName) { - _name = add({ - .type = FieldType::CardNumber, - .placeholder = tr::lng_payments_card_holder(), - .required = true, - }); - } auto container = inner->add( object_ptr( inner, @@ -128,12 +286,12 @@ not_null EditCard::setupContent() { _expire = std::make_unique(container, FieldConfig{ .type = FieldType::CardExpireDate, .placeholder = rpl::single(u"MM / YY"_q), - .required = true, + .validator = ExpireDateValidator(), }); _cvc = std::make_unique(container, FieldConfig{ .type = FieldType::CardCVC, .placeholder = rpl::single(u"CVC"_q), - .required = true, + .validator = CvcValidator([=] { return _number->value(); }), }); container->widthValue( ) | rpl::start_with_next([=](int width) { @@ -144,6 +302,24 @@ not_null EditCard::setupContent() { _expire->widget()->moveToLeft(0, 0, width); _cvc->widget()->moveToRight(0, 0, width); }, container->lifetime()); + + if (_native.needCardholderName) { + _name = add({ + .type = FieldType::CardNumber, + .placeholder = tr::lng_payments_card_holder(), + .validator = CardHolderNameValidator(), + }); + } + + _number->setNextField(_expire.get()); + _expire->setPreviousField(_number.get()); + _expire->setNextField(_cvc.get()); + _cvc->setPreviousField(_expire.get()); + if (_name) { + _cvc->setNextField(_name.get()); + _name->setPreviousField(_cvc.get()); + } + if (_native.needCountry || _native.needZip) { inner->add( object_ptr( @@ -156,18 +332,23 @@ not_null EditCard::setupContent() { _country = add({ .type = FieldType::Country, .placeholder = tr::lng_payments_billing_country(), + .validator = RequiredFinishedValidator(), .showBox = showBox, .defaultCountry = _native.defaultCountry, - .required = true, }); } if (_native.needZip) { _zip = add({ .type = FieldType::Text, .placeholder = tr::lng_payments_billing_zip_code(), - .maxLength = kMaxPostcodeSize, - .required = true, + .validator = RequiredValidator(), }); + if (_country) { + _country->finished( + ) | rpl::start_with_next([=] { + _zip->setFocus(); + }, lifetime()); + } } return inner; } @@ -198,7 +379,7 @@ void EditCard::updateControlsGeometry() { auto EditCard::lookupField(CardField field) const -> Field* { switch (field) { case CardField::Number: return _number.get(); - case CardField::CVC: return _cvc.get(); + case CardField::Cvc: return _cvc.get(); case CardField::ExpireDate: return _expire.get(); case CardField::Name: return _name.get(); case CardField::AddressCountry: return _country.get(); diff --git a/Telegram/SourceFiles/payments/ui/payments_edit_information.cpp b/Telegram/SourceFiles/payments/ui/payments_edit_information.cpp index 522878202c..d9b4f25628 100644 --- a/Telegram/SourceFiles/payments/ui/payments_edit_information.cpp +++ b/Telegram/SourceFiles/payments/ui/payments_edit_information.cpp @@ -26,6 +26,8 @@ constexpr auto kMaxPostcodeSize = 10; constexpr auto kMaxNameSize = 64; constexpr auto kMaxEmailSize = 128; constexpr auto kMaxPhoneSize = 16; +constexpr auto kMinCitySize = 2; +constexpr auto kMaxCitySize = 64; } // namespace @@ -112,18 +114,17 @@ not_null EditInformation::setupContent() { _street1 = add({ .placeholder = tr::lng_payments_address_street1(), .value = _information.shippingAddress.address1, - .maxLength = kMaxStreetSize, - .required = true, + .validator = RangeLengthValidator(1, kMaxStreetSize), }); _street2 = add({ .placeholder = tr::lng_payments_address_street2(), .value = _information.shippingAddress.address2, - .maxLength = kMaxStreetSize, + .validator = MaxLengthValidator(kMaxStreetSize), }); _city = add({ .placeholder = tr::lng_payments_address_city(), .value = _information.shippingAddress.city, - .required = true, + .validator = RangeLengthValidator(kMinCitySize, kMaxCitySize), }); _state = add({ .placeholder = tr::lng_payments_address_state(), @@ -133,44 +134,38 @@ not_null EditInformation::setupContent() { .type = FieldType::Country, .placeholder = tr::lng_payments_address_country(), .value = _information.shippingAddress.countryIso2, + .validator = RequiredFinishedValidator(), .showBox = showBox, .defaultCountry = _information.defaultCountry, - .required = true, }); _postcode = add({ .placeholder = tr::lng_payments_address_postcode(), .value = _information.shippingAddress.postcode, - .maxLength = kMaxPostcodeSize, - .required = true, + .validator = RangeLengthValidator(1, kMaxPostcodeSize), }); - //StreetValidate, // #TODO payments - //CityValidate, - //CountryValidate, - //CountryFormat, - //PostcodeValidate, } if (_invoice.isNameRequested) { _name = add({ .placeholder = tr::lng_payments_info_name(), .value = _information.name, - .maxLength = kMaxNameSize, - .required = true, + .validator = RangeLengthValidator(1, kMaxNameSize), }); } if (_invoice.isEmailRequested) { _email = add({ + .type = FieldType::Email, .placeholder = tr::lng_payments_info_email(), .value = _information.email, - .maxLength = kMaxEmailSize, - .required = true, + .validator = RangeLengthValidator(1, kMaxEmailSize), }); } if (_invoice.isPhoneRequested) { _phone = add({ + .type = FieldType::Phone, .placeholder = tr::lng_payments_info_phone(), .value = _information.phone, - .maxLength = kMaxPhoneSize, - .required = true, + .validator = RangeLengthValidator(1, kMaxPhoneSize), + .defaultPhone = _information.defaultPhone, }); } return inner; @@ -215,6 +210,8 @@ auto EditInformation::lookupField(InformationField field) const -> Field* { RequestedInformation EditInformation::collect() const { return { + .defaultPhone = _information.defaultPhone, + .defaultCountry = _information.defaultCountry, .name = _name ? _name->value() : QString(), .phone = _phone ? _phone->value() : QString(), .email = _email ? _email->value() : QString(), diff --git a/Telegram/SourceFiles/payments/ui/payments_field.cpp b/Telegram/SourceFiles/payments/ui/payments_field.cpp index 0fc86222a3..fe958b207d 100644 --- a/Telegram/SourceFiles/payments/ui/payments_field.cpp +++ b/Telegram/SourceFiles/payments/ui/payments_field.cpp @@ -9,8 +9,11 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/input_fields.h" #include "ui/boxes/country_select_box.h" +#include "ui/ui_utility.h" +#include "ui/special_fields.h" #include "data/data_countries.h" #include "base/platform/base_platform_info.h" +#include "base/event_filter.h" #include "styles/style_payments.h" namespace Payments::Ui { @@ -91,12 +94,18 @@ namespace { case FieldType::CardExpireDate: case FieldType::CardCVC: case FieldType::Country: - case FieldType::Phone: return CreateChild( wrap.get(), st::paymentsField, std::move(config.placeholder), Parse(config)); + case FieldType::Phone: + return CreateChild( + wrap.get(), + st::paymentsField, + std::move(config.placeholder), + ExtractPhonePrefix(config.defaultPhone), + Parse(config)); } Unexpected("FieldType in Payments::Ui::LookupMaskedField."); } @@ -115,6 +124,10 @@ Field::Field(QWidget *parent, FieldConfig &&config) if (_config.type == FieldType::Country) { setupCountry(); } + if (const auto &validator = config.validator) { + setupValidator(validator); + } + setupFrontBackspace(); } RpWidget *Field::widget() const { @@ -132,6 +145,14 @@ QString Field::value() const { _countryIso2); } +rpl::producer<> Field::frontBackspace() const { + return _frontBackspace.events(); +} + +rpl::producer<> Field::finished() const { + return _finished.events(); +} + void Field::setupMaskedGeometry() { Expects(_masked != nullptr); @@ -168,13 +189,136 @@ void Field::setupCountry() { _countryIso2 = iso2; _masked->setText(Data::CountryNameByISO2(iso2)); _masked->hideError(); - setFocus(); raw->closeBox(); }, _masked->lifetime()); + raw->boxClosing() | rpl::start_with_next([=] { + setFocus(); + }, _masked->lifetime()); _config.showBox(std::move(box)); }); } +void Field::setupValidator(Fn validator) { + Expects(validator != nullptr); + + const auto state = [=]() -> State { + if (_masked) { + const auto position = _masked->cursorPosition(); + const auto selectionStart = _masked->selectionStart(); + const auto selectionEnd = _masked->selectionEnd(); + return { + .value = value(), + .position = position, + .anchor = (selectionStart == selectionEnd + ? position + : (selectionStart == position) + ? selectionEnd + : selectionStart), + }; + } + const auto cursor = _input->textCursor(); + return { + .value = value(), + .position = cursor.position(), + .anchor = cursor.anchor(), + }; + }; + const auto save = [=] { + _was = state(); + }; + const auto setText = [=](const QString &text) { + if (_masked) { + _masked->setText(text); + } else { + _input->setText(text); + } + }; + const auto setPosition = [=](int position) { + if (_masked) { + _masked->setCursorPosition(position); + } else { + auto cursor = _input->textCursor(); + cursor.setPosition(position); + _input->setTextCursor(cursor); + } + }; + const auto validate = [=] { + if (_validating) { + return; + } + _validating = true; + const auto guard = gsl::finally([&] { + _validating = false; + save(); + }); + + const auto now = state(); + const auto result = validator(ValidateRequest{ + .wasValue = _was.value, + .wasPosition = _was.position, + .wasAnchor = _was.anchor, + .nowValue = now.value, + .nowPosition = now.position, + }); + const auto changed = (result.value != now.value); + if (changed) { + setText(result.value); + } + if (changed || result.position != now.position) { + setPosition(result.position); + } + if (result.finished) { + _finished.fire({}); + } else if (result.invalid) { + Ui::PostponeCall( + _masked ? (QWidget*)_masked : _input, + [=] { showErrorNoFocus(); }); + } + }; + if (_masked) { + QObject::connect(_masked, &QLineEdit::cursorPositionChanged, save); + QObject::connect(_masked, &MaskedInputField::changed, validate); + } else { + const auto raw = _input->rawTextEdit(); + QObject::connect(raw, &QTextEdit::cursorPositionChanged, save); + QObject::connect(_input, &InputField::changed, validate); + } +} + +void Field::setupFrontBackspace() { + const auto filter = [=](not_null e) { + const auto frontBackspace = (e->type() == QEvent::KeyPress) + && (static_cast(e.get())->key() == Qt::Key_Backspace) + && (_masked + ? (_masked->cursorPosition() == 0 + && _masked->selectionLength() == 0) + : (_input->textCursor().position() == 0 + && _input->textCursor().anchor() == 0)); + if (frontBackspace) { + _frontBackspace.fire({}); + } + return base::EventFilterResult::Continue; + }; + if (_masked) { + base::install_event_filter(_masked, filter); + } else { + base::install_event_filter(_input->rawTextEdit(), filter); + } +} + +void Field::setNextField(not_null field) { + finished() | rpl::start_with_next([=] { + field->setFocus(); + }, _masked ? _masked->lifetime() : _input->lifetime()); +} + +void Field::setPreviousField(not_null field) { + frontBackspace( + ) | rpl::start_with_next([=] { + field->setFocus(); + }, _masked ? _masked->lifetime() : _input->lifetime()); +} + void Field::setFocus() { if (_config.type == FieldType::Country) { _wrap->setFocus(); @@ -206,4 +350,12 @@ void Field::showError() { } } +void Field::showErrorNoFocus() { + if (_input) { + _input->showErrorNoFocus(); + } else { + _masked->showErrorNoFocus(); + } +} + } // namespace Payments::Ui diff --git a/Telegram/SourceFiles/payments/ui/payments_field.h b/Telegram/SourceFiles/payments/ui/payments_field.h index c82b4d2fe0..2061ff5331 100644 --- a/Telegram/SourceFiles/payments/ui/payments_field.h +++ b/Telegram/SourceFiles/payments/ui/payments_field.h @@ -31,14 +31,59 @@ enum class FieldType { Email, }; +struct FieldValidateRequest { + QString wasValue; + int wasPosition = 0; + int wasAnchor = 0; + QString nowValue; + int nowPosition = 0; +}; + +struct FieldValidateResult { + QString value; + int position = 0; + bool invalid = false; + bool finished = false; +}; + +[[nodiscard]] auto RangeLengthValidator(int minLength, int maxLength) { + return [=](FieldValidateRequest request) { + return FieldValidateResult{ + .value = request.nowValue, + .position = request.nowPosition, + .invalid = (request.nowValue.size() < minLength + || request.nowValue.size() > maxLength), + }; + }; +} + +[[nodiscard]] auto MaxLengthValidator(int maxLength) { + return RangeLengthValidator(0, maxLength); +} + +[[nodiscard]] auto RequiredValidator() { + return RangeLengthValidator(1, std::numeric_limits::max()); +} + +[[nodiscard]] auto RequiredFinishedValidator() { + return [=](FieldValidateRequest request) { + return FieldValidateResult{ + .value = request.nowValue, + .position = request.nowPosition, + .invalid = request.nowValue.isEmpty(), + .finished = !request.nowValue.isEmpty(), + }; + }; +} + struct FieldConfig { FieldType type = FieldType::Text; rpl::producer placeholder; QString value; + Fn validator; Fn)> showBox; + QString defaultPhone; QString defaultCountry; - int maxLength = 0; - bool required = false; }; class Field final { @@ -49,20 +94,40 @@ public: [[nodiscard]] object_ptr ownedWidget() const; [[nodiscard]] QString value() const; + [[nodiscard]] rpl::producer<> frontBackspace() const; + [[nodiscard]] rpl::producer<> finished() const; void setFocus(); void setFocusFast(); void showError(); + void showErrorNoFocus(); + + void setNextField(not_null field); + void setPreviousField(not_null field); private: + struct State { + QString value; + int position = 0; + int anchor = 0; + }; + using ValidateRequest = FieldValidateRequest; + using ValidateResult = FieldValidateResult; + void setupMaskedGeometry(); void setupCountry(); + void setupValidator(Fn validator); + void setupFrontBackspace(); const FieldConfig _config; const base::unique_qptr _wrap; + rpl::event_stream<> _frontBackspace; + rpl::event_stream<> _finished; InputField *_input = nullptr; MaskedInputField *_masked = nullptr; QString _countryIso2; + State _was; + bool _validating = false; }; diff --git a/Telegram/SourceFiles/payments/ui/payments_form_summary.cpp b/Telegram/SourceFiles/payments/ui/payments_form_summary.cpp index 68c5965c62..aae6bd1af3 100644 --- a/Telegram/SourceFiles/payments/ui/payments_form_summary.cpp +++ b/Telegram/SourceFiles/payments/ui/payments_form_summary.cpp @@ -15,6 +15,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/wrap/vertical_layout.h" #include "ui/wrap/fade_wrap.h" #include "ui/text/format_values.h" +#include "data/data_countries.h" #include "lang/lang_keys.h" #include "styles/style_payments.h" #include "styles/style_passport.h" @@ -267,7 +268,8 @@ void FormSummary::setupSections(not_null layout) { push(_information.shippingAddress.address2); push(_information.shippingAddress.city); push(_information.shippingAddress.state); - push(_information.shippingAddress.countryIso2); + push(Data::CountryNameByISO2( + _information.shippingAddress.countryIso2)); push(_information.shippingAddress.postcode); add( tr::lng_payments_shipping_address(), diff --git a/Telegram/SourceFiles/payments/ui/payments_panel_data.h b/Telegram/SourceFiles/payments/ui/payments_panel_data.h index cf97ac5c77..da920af39a 100644 --- a/Telegram/SourceFiles/payments/ui/payments_panel_data.h +++ b/Telegram/SourceFiles/payments/ui/payments_panel_data.h @@ -87,6 +87,7 @@ struct Address { }; struct RequestedInformation { + QString defaultPhone; QString defaultCountry; QString name; @@ -144,7 +145,7 @@ struct PaymentMethodDetails { enum class CardField { Number, - CVC, + Cvc, ExpireDate, Name, AddressCountry, diff --git a/Telegram/SourceFiles/ui/boxes/country_select_box.cpp b/Telegram/SourceFiles/ui/boxes/country_select_box.cpp index a4652d7462..a31a84fc2b 100644 --- a/Telegram/SourceFiles/ui/boxes/country_select_box.cpp +++ b/Telegram/SourceFiles/ui/boxes/country_select_box.cpp @@ -28,7 +28,7 @@ QString LastValidISO; class CountrySelectBox::Inner : public TWidget { public: - Inner(QWidget *parent, Type type); + Inner(QWidget *parent, const QString &iso, Type type); ~Inner(); void updateFilter(QString filter = QString()); @@ -93,10 +93,7 @@ CountrySelectBox::CountrySelectBox(QWidget*) CountrySelectBox::CountrySelectBox(QWidget*, const QString &iso, Type type) : _type(type) , _select(this, st::defaultMultiSelect, tr::lng_country_ph()) -, _ownedInner(this, type) { - if (Data::CountriesByISO2().contains(iso)) { - LastValidISO = iso; - } +, _ownedInner(this, iso, type) { } rpl::producer CountrySelectBox::countryChosen() const { @@ -169,7 +166,10 @@ void CountrySelectBox::setInnerFocus() { _select->setInnerFocus(); } -CountrySelectBox::Inner::Inner(QWidget *parent, Type type) +CountrySelectBox::Inner::Inner( + QWidget *parent, + const QString &iso, + Type type) : TWidget(parent) , _type(type) , _rowHeight(st::countryRowHeight) { @@ -177,6 +177,10 @@ CountrySelectBox::Inner::Inner(QWidget *parent, Type type) const auto &byISO2 = Data::CountriesByISO2(); + if (byISO2.contains(iso)) { + LastValidISO = iso; + } + _list.reserve(byISO2.size()); _namesList.reserve(byISO2.size()); diff --git a/Telegram/SourceFiles/ui/special_fields.cpp b/Telegram/SourceFiles/ui/special_fields.cpp index 7ed7f90a4e..a1099e4d70 100644 --- a/Telegram/SourceFiles/ui/special_fields.cpp +++ b/Telegram/SourceFiles/ui/special_fields.cpp @@ -7,16 +7,24 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "ui/special_fields.h" -#include "core/application.h" #include "lang/lang_keys.h" #include "data/data_countries.h" // Data::ValidPhoneCode #include "numbers.h" +#include + namespace Ui { namespace { constexpr auto kMaxUsernameLength = 32; +// Rest of the phone number, without country code (seen 12 at least), +// need more for service numbers. +constexpr auto kMaxPhoneTailLength = 32; + +// Max length of country phone code. +constexpr auto kMaxPhoneCodeLength = 4; + } // namespace CountryCodeInput::CountryCodeInput( @@ -130,7 +138,9 @@ void PhonePartInput::correctValue( ++digitCount; } } - if (digitCount > MaxPhoneTailLength) digitCount = MaxPhoneTailLength; + if (digitCount > kMaxPhoneTailLength) { + digitCount = kMaxPhoneTailLength; + } bool inPart = !_pattern.isEmpty(); int curPart = -1, leftInPart = 0; @@ -273,6 +283,14 @@ void UsernameInput::correctValue( setCorrectedText(now, nowCursor, now.mid(from, len), newPos); } +QString ExtractPhonePrefix(const QString &phone) { + const auto pattern = phoneNumberParse(phone); + if (!pattern.isEmpty()) { + return phone.mid(0, pattern[0]); + } + return QString(); +} + PhoneInput::PhoneInput( QWidget *parent, const style::InputField &st, @@ -324,7 +342,7 @@ void PhoneInput::correctValue( QString &now, int &nowCursor) { auto digits = now; - digits.replace(QRegularExpression(qsl("[^\\d]")), QString()); + digits.replace(QRegularExpression("[^\\d]"), QString()); _pattern = phoneNumberParse(digits); QString newPlaceholder; @@ -350,7 +368,7 @@ void PhoneInput::correctValue( } QString newText; - int oldPos(nowCursor), newPos(-1), oldLen(now.length()), digitCount = qMin(digits.size(), MaxPhoneCodeLength + MaxPhoneTailLength); + int oldPos(nowCursor), newPos(-1), oldLen(now.length()), digitCount = qMin(digits.size(), kMaxPhoneCodeLength + kMaxPhoneTailLength); bool inPart = !_pattern.isEmpty(), plusFound = false; int curPart = 0, leftInPart = inPart ? _pattern.at(curPart) : 0; diff --git a/Telegram/SourceFiles/ui/special_fields.h b/Telegram/SourceFiles/ui/special_fields.h index 487684c4de..c0d50b6d25 100644 --- a/Telegram/SourceFiles/ui/special_fields.h +++ b/Telegram/SourceFiles/ui/special_fields.h @@ -90,6 +90,8 @@ private: }; +[[nodiscard]] QString ExtractPhonePrefix(const QString &phone); + class PhoneInput : public MaskedInputField { public: PhoneInput( diff --git a/Telegram/cmake/lib_stripe.cmake b/Telegram/cmake/lib_stripe.cmake index 6785d983b5..49b0f791a6 100644 --- a/Telegram/cmake/lib_stripe.cmake +++ b/Telegram/cmake/lib_stripe.cmake @@ -21,6 +21,8 @@ PRIVATE stripe/stripe_card.h stripe/stripe_card_params.cpp stripe/stripe_card_params.h + stripe/stripe_card_validator.cpp + stripe/stripe_card_validator.h stripe/stripe_decode.cpp stripe/stripe_decode.h stripe/stripe_error.cpp diff --git a/Telegram/cmake/td_ui.cmake b/Telegram/cmake/td_ui.cmake index 66b94f63fb..75d5226511 100644 --- a/Telegram/cmake/td_ui.cmake +++ b/Telegram/cmake/td_ui.cmake @@ -146,7 +146,9 @@ PRIVATE ui/cached_round_corners.h ui/grouped_layout.cpp ui/grouped_layout.h - + ui/special_fields.cpp + ui/special_fields.h + ui/ui_pch.h ) @@ -163,4 +165,5 @@ PUBLIC PRIVATE desktop-app::lib_ffmpeg desktop-app::lib_webview + desktop-app::lib_stripe )