Skip to content

Commit

Permalink
Added support for ExpressPay and EcobankPay EMV QR codes and fixed so…
Browse files Browse the repository at this point in the history
…me validation issues.

- Added test for 5 live EMV QR codes that failed to be parsed but are actually valid.
- Fixed merchant information tag ID range validation, it can be from 2 to 51 and it's even extracted in that range.
- Fixed validation failures when there is no additional data or merchant language template (both are optional).
- Fixed Issue juanroman-zz#2 by checking there is available length to substring before attempting it.
- Fixed some code analysis warnings.
- Assume if the merchant information has no child nodes (not a child TLV) then the Globally Unique Identifier IS the string, this sis the case for Visa and MasterCard PAN/Alias/Card Numbers present in actual EMV QR codes.

Issue juanroman-zz#2
  • Loading branch information
brutaldev committed Oct 18, 2019
1 parent 8df5827 commit 4ad8dff
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 38 deletions.
2 changes: 1 addition & 1 deletion src/StandardizedQR/MerchantPayload.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ public IEnumerable<ValidationResult> Validate(ValidationContext validationContex

if (null != MerchantAccountInformation && 1 <= MerchantAccountInformation.Count)
{
var invalidIdentifiers = MerchantAccountInformation.Keys.Count(k => k < 26 || k > 51);
var invalidIdentifiers = MerchantAccountInformation.Keys.Count(k => k < 2 || k > 51);
if (0 < invalidIdentifiers)
{
errors.Add(new ValidationResult(LibraryResources.MerchantAccountInformationInvalidIdentifier, new string[] { nameof(MerchantAccountInformation) }));
Expand Down
75 changes: 56 additions & 19 deletions src/StandardizedQR/Services/Decoding/MerchantDecoder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,28 @@ public MerchantPayload BuildPayload(ICollection<Tlv> tlvs)
DecodeAccountInformation(tlvs, merchantPayload);
DecodeUnreservedTemplate(tlvs, merchantPayload);

// Before validation we can remove additional data and the merchant language template if no data for them was available.
// They are optional fields but if they are not populated then validation fails.
if (null == merchantPayload.AdditionalData.AdditionalConsumerDataRequest
&& null == merchantPayload.AdditionalData.BillNumber
&& null == merchantPayload.AdditionalData.CustomerLabel
&& null == merchantPayload.AdditionalData.LoyaltyNumber
&& null == merchantPayload.AdditionalData.MobileNumber
&& null == merchantPayload.AdditionalData.PurposeOfTransaction
&& null == merchantPayload.AdditionalData.ReferenceLabel
&& null == merchantPayload.AdditionalData.StoreLabel
&& null == merchantPayload.AdditionalData.TerminalLabel)
{
merchantPayload.AdditionalData = null;
}

if (null == merchantPayload.MerchantInformation.LanguagePreference
&& null == merchantPayload.MerchantInformation.MerchantCityAlternateLanguage
&& null == merchantPayload.MerchantInformation.MerchantNameAlternateLanguage)
{
merchantPayload.MerchantInformation = null;
}

return merchantPayload;
}

Expand Down Expand Up @@ -96,6 +118,11 @@ private void ParseTLVs(string data, ICollection<Tlv> tlvs)
}
index += 2;

if (data.Length - 4 < length)
{
break;
}

var value = data.Substring(index, length);
index += length - 1;

Expand Down Expand Up @@ -140,22 +167,32 @@ private void DecodeAccountInformation(ICollection<Tlv> tlvs, MerchantPayload mer
foreach (var tlv in merchantAccountInfoTlvs)
{
var accountInfo = new MerchantAccountInformation();
var globalUniqueIdentifierTlv = tlv.ChildNodes.FirstOrDefault(t => t.Tag == 0);
if (null != globalUniqueIdentifierTlv)

// Visa and MasterCard simply have card numbers in their reserved space (02 and 04 for example - Issue #2).
// If there are no child nodes, then the data for this tag is not a TLV string, we could probably assume it's the GlobalUniqueIdentifier since it's a required field.
if (!tlv.ChildNodes.Any())
{
accountInfo.GlobalUniqueIdentifier = globalUniqueIdentifierTlv.Value;
accountInfo.GlobalUniqueIdentifier = tlv.Value;
}

var paymentNetworkSpecificTlvs = tlv.ChildNodes.Where(e => e.Tag >= 1 && e.Tag <= 99);
if (paymentNetworkSpecificTlvs.Any())
else
{
accountInfo.PaymentNetworkSpecific = new Dictionary<int, string>();
foreach (var item in paymentNetworkSpecificTlvs)
var globalUniqueIdentifierTlv = tlv.ChildNodes.FirstOrDefault(t => t.Tag == 0);
if (null != globalUniqueIdentifierTlv)
{
accountInfo.PaymentNetworkSpecific.Add(item.Tag, item.Value);
accountInfo.GlobalUniqueIdentifier = globalUniqueIdentifierTlv.Value;

var paymentNetworkSpecificTlvs = tlv.ChildNodes.Where(e => e.Tag >= 1 && e.Tag <= 99);
if (paymentNetworkSpecificTlvs.Any())
{
accountInfo.PaymentNetworkSpecific = new Dictionary<int, string>();
foreach (var item in paymentNetworkSpecificTlvs)
{
accountInfo.PaymentNetworkSpecific.Add(item.Tag, item.Value);
}
}
}
}

merchantPayload.MerchantAccountInformation.Add(tlv.Tag, accountInfo);
}
}
Expand All @@ -174,19 +211,19 @@ private void DecodeUnreservedTemplate(ICollection<Tlv> tlvs, MerchantPayload mer
if (null != globalUniqueIdentifierTlv)
{
unreservedTemplate.GlobalUniqueIdentifier = globalUniqueIdentifierTlv.Value;
}

var contextSpecificTlvs = tlv.ChildNodes.Where(e => e.Tag >= 1 && e.Tag <= 99);
if (contextSpecificTlvs.Any())
{
unreservedTemplate.ContextSpecificData = new Dictionary<int, string>();
foreach (var item in contextSpecificTlvs)
var contextSpecificTlvs = tlv.ChildNodes.Where(e => e.Tag >= 1 && e.Tag <= 99);
if (contextSpecificTlvs.Any())
{
unreservedTemplate.ContextSpecificData.Add(item.Tag, item.Value);
unreservedTemplate.ContextSpecificData = new Dictionary<int, string>();
foreach (var item in contextSpecificTlvs)
{
unreservedTemplate.ContextSpecificData.Add(item.Tag, item.Value);
}
}
}

merchantPayload.UnreservedTemplate.Add(tlv.Tag, unreservedTemplate);
merchantPayload.UnreservedTemplate.Add(tlv.Tag, unreservedTemplate);
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/StandardizedQR/Services/Encoding/IPayloadEncoding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
{
public interface IPayloadEncoding<T>
{
string GeneratePayload(T instance);
string GeneratePayload(T payload);
}
}
11 changes: 6 additions & 5 deletions src/StandardizedQR/Services/Encoding/MerchantEncoder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public string GeneratePayload(MerchantPayload payload)
* ID, Length and Value, to be included in the QR Code, in their respective order, as well as the ID and Length of
* the CRC itself (but excluding its Value).
*/
sb.Append("6304"); // {id:63}{length:04}
sb.Append("6304"); //// {id:63}{length:04}
var crc16ccittFalseParameters = CrcStdParams.StandartParameters[CrcAlgorithms.Crc16CcittFalse];
var crc = new Crc(crc16ccittFalseParameters).ComputeHash(System.Text.Encoding.UTF8.GetBytes(sb.ToString()));
sb.Append(crc.ToHex(true).GetLast(4));
Expand All @@ -115,15 +115,16 @@ private string EncodeProperty<T>(PropertyInfo property, T propertyValue)
var emvSpecAttribute = (EmvSpecificationAttribute)property
.GetCustomAttributes(typeof(EmvSpecificationAttribute), false)
.First();

string id = emvSpecAttribute.Id.ToString("D2");

string value = EncodePropertyValue(propertyValue);
string length = value.Length.ToString("D2");


if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}

string id = emvSpecAttribute.Id.ToString("D2");
string length = value.Length.ToString("D2");

return $"{id}{length}{value}";
}
Expand Down
14 changes: 8 additions & 6 deletions src/StandardizedQR/Validation/ValidateObjectAttribute.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Globalization;

namespace StandardizedQR.Validation
{
/// <summary>
/// Helper attribute that allows for recursive valdation using data annotations.
/// </summary>
/// <seealso cref="ValidationAttribute" />
public class ValidateObjectAttribute : ValidationAttribute
[AttributeUsage(AttributeTargets.All, AllowMultiple = false)]
/// <summary>
/// Helper attribute that allows for recursive validation using data annotations.
/// </summary>
/// <seealso cref="ValidationAttribute" />
public class ValidateObjectAttribute : ValidationAttribute
{
/// <summary>
/// Returns true if ... is valid.
Expand Down
121 changes: 115 additions & 6 deletions test/StandardizedQR.XUnitTests/MerchantPayloadUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,115 @@ public void DecodeQR()
Assert.Equal("12345678", payload.UnreservedTemplate[91].ContextSpecificData[7]);
}

[Fact]
public void DecodeQR1()
{
var qrData = "0002010102110213404587173785204155326311010619815204829953039365802GH5909CIB GHANA6005ACCRA622407088656730603088656730663041437";
var payload = MerchantPayload.FromQR(qrData);

Assert.Equal(1, payload.PayloadFormatIndicator);
Assert.Equal(11, payload.PointOfInitializationMethod);
Assert.Equal("1437", payload.CRC);
Assert.Equal(8299, payload.MerchantCategoryCode);
Assert.Equal(936, payload.TransactionCurrency);
Assert.Equal("CIB GHANA", payload.MerchantName);
Assert.Equal("ACCRA", payload.MerchantCity);
Assert.Equal("GH", payload.CountyCode);

Assert.Equal("4045871737852", payload.MerchantAccountInformation[2].GlobalUniqueIdentifier);
Assert.Equal("532631101061981", payload.MerchantAccountInformation[4].GlobalUniqueIdentifier);

Assert.Equal("86567306", payload.AdditionalData.StoreLabel);
Assert.Equal("86567306", payload.AdditionalData.TerminalLabel);
}

[Fact]
public void DecodeQR2()
{
var qrData = "0002010102110213404587194150404155326311017361105204581153039365802GH5913SUSAN ALLOTEY6005ACCRA622407080407330503080407330563049EE4";
var payload = MerchantPayload.FromQR(qrData);

Assert.Equal(1, payload.PayloadFormatIndicator);
Assert.Equal(11, payload.PointOfInitializationMethod);
Assert.Equal("9EE4", payload.CRC);
Assert.Equal(5811, payload.MerchantCategoryCode);
Assert.Equal(936, payload.TransactionCurrency);
Assert.Equal("SUSAN ALLOTEY", payload.MerchantName);
Assert.Equal("ACCRA", payload.MerchantCity);
Assert.Equal("GH", payload.CountyCode);

Assert.Equal("4045871941504", payload.MerchantAccountInformation[2].GlobalUniqueIdentifier);
Assert.Equal("532631101736110", payload.MerchantAccountInformation[4].GlobalUniqueIdentifier);

Assert.Equal("04073305", payload.AdditionalData.StoreLabel);
Assert.Equal("04073305", payload.AdditionalData.TerminalLabel);
}

[Fact]
public void DecodeQR3()
{
var qrData = "0002010102110213404587568745904155326311155509945204625353039365802GH5915MAXMART LIMITED6005ACCRA62240708620037450308620037456304C913";
var payload = MerchantPayload.FromQR(qrData);

Assert.Equal(1, payload.PayloadFormatIndicator);
Assert.Equal(11, payload.PointOfInitializationMethod);
Assert.Equal("C913", payload.CRC);
Assert.Equal(6253, payload.MerchantCategoryCode);
Assert.Equal(936, payload.TransactionCurrency);
Assert.Equal("MAXMART LIMITED", payload.MerchantName);
Assert.Equal("ACCRA", payload.MerchantCity);
Assert.Equal("GH", payload.CountyCode);

Assert.Equal("4045875687459", payload.MerchantAccountInformation[2].GlobalUniqueIdentifier);
Assert.Equal("532631115550994", payload.MerchantAccountInformation[4].GlobalUniqueIdentifier);

Assert.Equal("62003745", payload.AdditionalData.StoreLabel);
Assert.Equal("62003745", payload.AdditionalData.TerminalLabel);
}

[Fact]
public void DecodeQR4()
{
var qrData = "0002010102110213404587793527804155326311019494035204529553039365802GH5915JULITET LIMITED6005ACCRA62240708324313220308324313226304A5FA";
var payload = MerchantPayload.FromQR(qrData);

Assert.Equal(1, payload.PayloadFormatIndicator);
Assert.Equal(11, payload.PointOfInitializationMethod);
Assert.Equal("A5FA", payload.CRC);
Assert.Equal(5295, payload.MerchantCategoryCode);
Assert.Equal(936, payload.TransactionCurrency);
Assert.Equal("JULITET LIMITED", payload.MerchantName);
Assert.Equal("ACCRA", payload.MerchantCity);
Assert.Equal("GH", payload.CountyCode);

Assert.Equal("4045877935278", payload.MerchantAccountInformation[2].GlobalUniqueIdentifier);
Assert.Equal("532631101949403", payload.MerchantAccountInformation[4].GlobalUniqueIdentifier);

Assert.Equal("32431322", payload.AdditionalData.StoreLabel);
Assert.Equal("32431322", payload.AdditionalData.TerminalLabel);
}

[Fact]
public void DecodeQR5()
{
var qrData = "00020101021102154382871085619335204541153039365802GH5907PANDORA6005Accra63049C22";
var payload = MerchantPayload.FromQR(qrData);

Assert.Equal(1, payload.PayloadFormatIndicator);
Assert.Equal(11, payload.PointOfInitializationMethod);
Assert.Equal("9C22", payload.CRC);
Assert.Equal(5411, payload.MerchantCategoryCode);
Assert.Equal(936, payload.TransactionCurrency);
Assert.Equal("PANDORA", payload.MerchantName);
Assert.Equal("Accra", payload.MerchantCity);
Assert.Equal("GH", payload.CountyCode);

Assert.True(payload.MerchantAccountInformation.Count == 1);
Assert.Equal("438287108561933", payload.MerchantAccountInformation[2].GlobalUniqueIdentifier);

Assert.Null(payload.AdditionalData);
}

[Fact]
public void PayloadWithSpecificationSample()
{
Expand Down Expand Up @@ -213,7 +322,7 @@ public void InvalidMerchantAccountInformationIdentifiers()
MerchantCity = "Mexico City",
};
var payload = merchantPayload.GeneratePayload();
merchantPayload.GeneratePayload();
});
}

Expand Down Expand Up @@ -245,7 +354,7 @@ public void InvalidMerchantAccountInformationPaymentSpecificItems()
MerchantCity = "Mexico City",
};
var payload = merchantPayload.GeneratePayload();
merchantPayload.GeneratePayload();
});
}

Expand All @@ -269,7 +378,7 @@ public void InvalidPayloadFormatIndicator()
MerchantCity = "Mexico City",
};
var payload = merchantPayload.GeneratePayload();
merchantPayload.GeneratePayload();
});
}

Expand All @@ -294,7 +403,7 @@ public void InvalidTipOrConvenienceIndicator()
TipOrConvenienceIndicator = 5
};
var payload = merchantPayload.GeneratePayload();
merchantPayload.GeneratePayload();
});
}

Expand All @@ -319,7 +428,7 @@ public void MissingFixedTip()
TipOrConvenienceIndicator = 2
};
var payload = merchantPayload.GeneratePayload();
merchantPayload.GeneratePayload();
});
}

Expand All @@ -344,7 +453,7 @@ public void MissingPercentageTip()
TipOrConvenienceIndicator = 3
};
var payload = merchantPayload.GeneratePayload();
merchantPayload.GeneratePayload();
});
}
}
Expand Down

0 comments on commit 4ad8dff

Please sign in to comment.