Add local validation for card information.

This commit is contained in:
John Preston 2021-03-29 16:16:54 +04:00
parent e077163322
commit 0af6c4b0b6
23 changed files with 950 additions and 98 deletions

View File

@ -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

View File

@ -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());

View File

@ -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,

View File

@ -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:

View File

@ -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

View File

@ -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<Ui::MaskedInputField>(

View File

@ -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 });
}

View File

@ -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<StripePaymentMethod>(&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) {

View File

@ -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<Main::Session*> _session;
MTP::Sender _api;

View File

@ -20,6 +20,7 @@ enum class CardBrand {
Discover,
JCB,
DinersClub,
UnionPay,
Unknown,
};

View File

@ -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 <QtCore/QDate>
namespace Stripe {
namespace {
constexpr auto kMinCvcLength = 3;
struct BinRange {
QString low;
QString high;
int length = 0;
CardBrand brand = CardBrand::Unknown;
};
[[nodiscard]] const std::vector<BinRange> &AllRanges() {
static auto kResult = std::vector<BinRange>{
// 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<BinRange> BinRangesForNumber(
const QString &sanitized) {
const auto &all = AllRanges();
auto result = std::vector<BinRange>();
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<CardBrand> PossibleBrandsForNumber(
const QString &sanitized) {
const auto ranges = BinRangesForNumber(sanitized);
auto result = std::vector<CardBrand>();
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<int> 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

View File

@ -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<int> CardNumberFormat(const QString &number);
} // namespace Stripe

View File

@ -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 <QtCore/QRegularExpression>
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<ValueValidator>()(QString()))>
[[nodiscard]] auto ComplexNumberValidator(
ValueValidator valueValidator,
Fn<SimpleFieldState(SimpleFieldState)> 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<QString()> 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<RpWidget*> 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<FixedHeightWidget>(
inner,
@ -128,12 +286,12 @@ not_null<RpWidget*> EditCard::setupContent() {
_expire = std::make_unique<Field>(container, FieldConfig{
.type = FieldType::CardExpireDate,
.placeholder = rpl::single(u"MM / YY"_q),
.required = true,
.validator = ExpireDateValidator(),
});
_cvc = std::make_unique<Field>(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<RpWidget*> 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<Ui::FlatLabel>(
@ -156,18 +332,23 @@ not_null<RpWidget*> 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();

View File

@ -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<RpWidget*> 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<RpWidget*> 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(),

View File

@ -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<MaskedInputField>(
wrap.get(),
st::paymentsField,
std::move(config.placeholder),
Parse(config));
case FieldType::Phone:
return CreateChild<PhoneInput>(
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<ValidateResult(ValidateRequest)> 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<QEvent*> e) {
const auto frontBackspace = (e->type() == QEvent::KeyPress)
&& (static_cast<QKeyEvent*>(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*> field) {
finished() | rpl::start_with_next([=] {
field->setFocus();
}, _masked ? _masked->lifetime() : _input->lifetime());
}
void Field::setPreviousField(not_null<Field*> 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

View File

@ -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<int>::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<QString> placeholder;
QString value;
Fn<FieldValidateResult(FieldValidateRequest)> validator;
Fn<void(object_ptr<BoxContent>)> showBox;
QString defaultPhone;
QString defaultCountry;
int maxLength = 0;
bool required = false;
};
class Field final {
@ -49,20 +94,40 @@ public:
[[nodiscard]] object_ptr<RpWidget> 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*> field);
void setPreviousField(not_null<Field*> 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<ValidateResult(ValidateRequest)> validator);
void setupFrontBackspace();
const FieldConfig _config;
const base::unique_qptr<RpWidget> _wrap;
rpl::event_stream<> _frontBackspace;
rpl::event_stream<> _finished;
InputField *_input = nullptr;
MaskedInputField *_masked = nullptr;
QString _countryIso2;
State _was;
bool _validating = false;
};

View File

@ -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<VerticalLayout*> 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(),

View File

@ -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,

View File

@ -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<QString> 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());

View File

@ -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 <QtCore/QRegularExpression>
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;

View File

@ -90,6 +90,8 @@ private:
};
[[nodiscard]] QString ExtractPhonePrefix(const QString &phone);
class PhoneInput : public MaskedInputField {
public:
PhoneInput(

View File

@ -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

View File

@ -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
)