Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for ExpressPay and EcobankPay EMV QR codes and fixed some validation issues. #3

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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