Validating and Formatting Payment Card Text Fields in Flutter

Wilberforce Uwadiegwu
Flutter Community
Published in
6 min readJan 28, 2020

I am going to walk us through the steps of validating payment (debit/credit) cards number, expiry date and CVV. Among other things, this is basically building on the already existing features of the TextFormField widget.

We’ll start with the card number: it will be validated and the appropriate icon of the issuer displayed.

Before, you continue, please make sure you know how to validate forms in Flutter.

Validating Payment Card Number, Determining its Issuer and Formatting the Text Field

Payment card numbers might appear random, but they actually follow a certain pattern. The length varies from 8 to 19 digits and while the authenticity of these numbers can be verified using the Luhn algorithm, the first six digits can be used to determine the card issuer. For example, numbers starting with 4 are issued by Visa while those issued by Verve starts from 5060, 5061, 5078, 5079, or 6500. You can check this Wikipedia page for the full article.

We’ll create an enumeration of all the card types we wish to support. For example purposes, I keep it down to just three viz. Verve, MasterCard and Verve.

enum CardType {
MasterCard,
Visa,
Verve,
Others, // Any other card issuer
Invalid // We'll use this when the card is invalid
}

We’ll also create a class to hold the fields of the card:

class PaymentCard {
CardType type;
String number;
String name;
int month;
int year;
int cvv;

PaymentCard(
{this.type, this.number, this.name, this.month, this.year, this.cvv});
}

Now, we’ll create a TextFormField for entering the card number.

new TextFormField(
keyboardType: TextInputType.number,
inputFormatters: [
WhitelistingTextInputFormatter.digitsOnly,
new LengthLimitingTextInputFormatter(19),
],
controller: numberController,
decoration: new InputDecoration(
...,
icon: CardUtils.getCardIcon(_paymentCard.type),
...
),
onSaved: (String value) {
_paymentCard.number =
CardUtils.getCleanedNumber(value);
},
validator: CardUtils.validateCardNumWithLuhnAlgorithm,
),

Starting from inputFormatters, I will explain the parameters sequentially.

  1. inputFormatters are for performing as-you-type formatting of text fields. Passing TextInputType.number to the keyboardType is not enough to enforce a digits-only text field. While it’ll ensure that only a numerical keyboard is called up, characters like point, comma and hyphen can still be inputted. So, using WhitelistingTextInputFormatter.digitsOnly ensures that only numerical characters are accepted by the text field while LengthLimitingTextInputFormatter enforces a character limit. In our case, we know a payment card number cannot exceed 19, so we pass that to the instantiation of LengthLimitingTextInputFormatter.

2. We pass an instance of TextEditingController to controller. TextEditingController is for listening for changes to the text field, we’ll be notified via a passed function each time a character is entered/removed from the field. In our case, it’ll be used to determine the card issuer. Please read the official documentation of TextEditingControllers for further clarification.

In initState, we initialize the controller and pass a function to it. The function looks like this:

void _getCardTypeFrmNumber() {
String input = CardUtils.getCleanedNumber(numberController.text);
CardType cardType = CardUtils.getCardTypeFrmNumber(input);
setState(() {
this._cardType = cardType;
});
}

This function uses CardUtils.getCardTypeFrmNumber to determine the card issuer from the number already entered by the user.

CardUtils.getCleanedNumber uses regex to remove any non-digit from the inputted number. Confused? You thought that’s what WhitelistingTextInputFormatter.digitsOnly is supposed to do, right? I will explain later.

CardUtils.getCardTypeFrmNumber too uses regex to match the starting characters of the number against a pre-determined set of numbers:

After determining the card issuer, we use:

setState(() {
this._cardType = cardType;
});

to update the widget state.

3. Next, CardUtils.getCardIcon returns the appropriate issuer icon. By now, you should have added image assets of the card types you’re supporting. Now, we’ll write a switch statement to check for the card types and return the appropriate widget.

In my project, images live in projectDirectory/assets/images.

4. In onSaved, we’re cleaning the inputted values of non-digit characters (I know, I will explain the reason soon). Don’t know when onSaved is called? It’s part of Flutter’s built-in way of validating forms and user inputs.

5. The actual validation is done in validator. We’ll pull off this validation with Luhn’s algorithm:

To learn more about Luhn’s Algorithm and how it works, please check this stackoverflow answer.

Formatting Card Number Text Field

To improve readability, we can format the TextFormField to automatically add double spaces after every fourth character inputted by the user. Remember inputFormatters I talked about earlier? We will extend TextInputFormatter and add an instance of it to the list of inputFormatters we already have.

class CardNumberInputFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue, TextEditingValue newValue) {
var text = newValue.text;

if (newValue.selection.baseOffset == 0) {
return newValue;
}

var buffer = new StringBuffer();
for (int i = 0; i < text.length; i++) {
buffer.write(text[i]);
var nonZeroIndex = i + 1;
if (nonZeroIndex % 4 == 0 && nonZeroIndex != text.length) {
buffer.write(' '); // Add double spaces.
}
}

var string = buffer.toString();
return newValue.copyWith(
text: string,
selection: new TextSelection.collapsed(offset: string.length));
}
}

It’s pertinent to note that the extra characters added doesn’t add the length of the characters counted by LengthLimitingTextInputFormatter.

So inputFormatters for the TextFormField now becomes:

inputFormatters: [
WhitelistingTextInputFormatter.digitsOnly,
new LengthLimitingTextInputFormatter(19),
new CardNumberInputFormatter()
],

Remember, I promised to explain why we’re cleaning the number? The value returned by onSaved and numberController.text will have the spaces we added with CardNumberInputFormatter. So we have to remove the non-digits from it before actually passing it to the validator or sending to the payment gateway.

How to Validate Payment Card Expiry Date and Format the Text Field.

A valid expiry card is one whose month is from 1 to 12 (i.e. January to December) and whose year is not negative. Also, an unexpired date should be the be latter than this month or year. We should keep this in mind while validating the expiry date.

new TextFormField(
inputFormatters: [
WhitelistingTextInputFormatter.digitsOnly,
new LengthLimitingTextInputFormatter(4),
new CardMonthInputFormatter()
],
decoration: new InputDecoration(
...
),
validator: CardUtils.validateDate,
keyboardType: TextInputType.number,
onSaved: (value) {
List<int> expiryDate = CardUtils.getExpiryDate(value);
_paymentCard.month = expiryDate[0];
_paymentCard.year = expiryDate[1];
},

That’s the nitty-gritty of the TextFormField for inputting expiry month.

  1. With LengthLimitingTextInputFormatter(4), we’re limiting the number of characters that can be inputted to 4.
  2. CardMonthInputFormatter is to automatically add a forward-slash (“/”) between the month and year. It’s not too different from the CardNumberInputFormatter above.
...
var
buffer = new StringBuffer();
for (int i = 0; i < newText.length; i++) {
buffer.write(newText[i]);
var nonZeroIndex = i + 1;
if (nonZeroIndex % 2 == 0 && nonZeroIndex != newText.length) {
buffer.write('/');
}
}
...

Part of the for loop is the only difference.

3. CardUtils.validateDate actually checks that the expiry date is valid and has not expired.

4. In onSaved, we’re simply splitting the date and using the first index of the split as the month and the second as the year.

static List<int> getExpiryDate(String value) {
var split = value.split(new RegExp(r'(\/)'));
return [int.parse(split[0]), int.parse(split[1])];
}

How to Validate Payment Card CVV and Format the TextField.

This is the simplest. Payments cards CVV is either 3 or 4 digits. It can’t be less/more than that.

new TextFormField(
inputFormatters: [
WhitelistingTextInputFormatter.digitsOnly,
new LengthLimitingTextInputFormatter(4),
],
decoration: new InputDecoration(
...,
),
validator: CardUtils.validateCVV,
keyboardType: TextInputType.number,
onSaved: (value) {
_paymentCard.cvv = int.parse(value);
},
),

CardUtils.validateCVV is quite straight forward. We just ensure that the value entered by the user is not empty and it’s not less than 3 or more than 4 digits:

static String validateCVV(String value) {
if (value.isEmpty) {
return Strings.fieldReq;
}

if (value.length < 3 || value.length > 4) {
return "CVV is invalid";
}
return null;
}

Wrapping it all up

For easier validation, all the TextFormFields should be children of the Form widget.

new Form(
key: _formKey,
autovalidate: _autoValidate,
child: new ListView(
children: <Widget>[
// TextFormFields
// Validation button
],
)),

Then throw in a button that does nothing but call _validateInputs:

void _validateInputs() {
final FormState form = _formKey.currentState;
if (!form.validate()) {
setState(() {
_autoValidate = true; // Start validating on every change.
});
_showInSnackBar('Please fix the errors in red before submitting.');
} else {
form.save();
// Encrypt and send send card details to payment the gateway
_showInSnackBar('Payment card is valid');
}
}

I have pushed a project that you can clone and test out the above features. I also added support for more payments cards like Discover, American Express, Diners Club, and JCB.

--

--