Skip to content

Instantly share code, notes, and snippets.

@louthy
Last active September 15, 2024 20:38
Show Gist options
  • Save louthy/09d949f0a62a0061989b64dd4903167f to your computer and use it in GitHub Desktop.
Save louthy/09d949f0a62a0061989b64dd4903167f to your computer and use it in GitHub Desktop.
using System.Numerics;
using LanguageExt;
using LanguageExt.Common;
using LanguageExt.Traits;
using static LanguageExt.Prelude;
namespace ValidationExamples;
// Credit card number
public record CardNumber(Seq<int> Number)
{
public override string ToString() =>
$"{Number}";
}
// Expiry date
public record Expiry(int Month, int Year) :
IAdditionOperators<Expiry, Expiry, Expiry>,
IComparisonOperators<Expiry, Expiry, bool>
{
public static readonly Expiry OneMonth = new (1, 0);
public static Expiry operator +(Expiry left, Expiry right)
{
var m = left.Month + right.Month;
var y = left.Year + right.Year;
while (m > 12) m -= 12;
return new Expiry(m, y);
}
public static bool operator >(Expiry left, Expiry right) =>
left.Year > right.Year ||
left.Year == right.Year && left.Month > right.Month;
public static bool operator >=(Expiry left, Expiry right) =>
left.Year > right.Year ||
left.Year == right.Year && left.Month >= right.Month;
public static bool operator <(Expiry left, Expiry right) =>
left.Year < right.Year ||
left.Year == right.Year && left.Month < right.Month;
public static bool operator <=(Expiry left, Expiry right) =>
left.Year < right.Year ||
left.Year == right.Year && left.Month <= right.Month;
public static Expiry Now
{
get
{
var now = DateTime.Now;
return new Expiry(now.Month, now.Year);
}
}
public static Range<Expiry> NextTenYears =>
LanguageExt.Range.fromMinMax(Now, Now + new Expiry(0, 10), new Expiry(1, 0));
public override string ToString() =>
$"{Month}/{Year}";
}
// CVV code
public record CVV(int Number)
{
public override string ToString() =>
$"{Number}";
}
// Complete credit card details
public record CreditCardDetails(CardNumber CardNumber, Expiry Expiry, CVV CVV)
{
public static CreditCardDetails Make(CardNumber cardNo, Expiry expiry, CVV cvv) =>
new (cardNo, expiry, cvv);
public override string ToString() =>
$"CreditCard({CardNumber}, {Expiry}, {CVV})";
}
public static class CreditCard
{
public static void Test()
{
Console.WriteLine(Validate("4560005094752584", "12-2024", "123"));
Console.WriteLine(Validate("00000", "00-2345", "WXYZ"));
}
public static Validation<Error, CreditCardDetails> Validate(string cardNo, string expiryDate, string cvv) =>
fun<CardNumber, Expiry, CVV, CreditCardDetails>(CreditCardDetails.Make)
.Map(ValidateCardNumber(cardNo))
.Apply(ValidateExpiryDate(expiryDate))
.Apply(ValidateCVV(cvv))
.As();
static Validation<Error, CardNumber> ValidateCardNumber(string cardNo) =>
(ValidateAllDigits(cardNo), ValidateLength(cardNo, 16))
.Apply((digits, _) => digits.ToSeq())
.Bind(ValidateLuhn)
.Map(digits => new CardNumber(digits))
.As()
.MapFail(e => Error.New("card number not valid", e));
static Validation<Error, Expiry> ValidateExpiryDate(string expiryDate) =>
expiryDate.Split(['\\', '/', '-', ' ']) switch
{
[var month, var year] =>
from my in ValidateInt(month) & ValidateInt(year)
let exp = new Expiry(my[0], my[1])
from _ in ValidateInRange(exp, Expiry.NextTenYears )
select exp,
_ => Fail(Error.New($"expected expiry-date in the format: MM/YYYY, but got: {expiryDate}"))
};
static Validation<Error, A> ValidateInRange<A>(A value, Range<A> range)
where A : IAdditionOperators<A, A, A>,
IComparisonOperators<A, A, bool> =>
range.InRange(value)
? Pure(value)
: Fail(Error.New($"expected value in range of {range.From} to {range.To}, but got: {value}"));
static Validation<Error, CVV> ValidateCVV(string cvv) =>
fun<int, string, CVV>((code, _) => new CVV(code))
.Map(ValidateInt(cvv).MapFail(_ => Error.New("CVV code should be a number")))
.Apply(ValidateLength(cvv, 3).MapFail(_ => Error.New("CVV code should be 3 digits in length")))
.As();
static Validation<Error, EnumerableM<int>> ValidateAllDigits(string value) =>
value.AsEnumerableM()
.Traverse(CharToDigit)
.As();
static Validation<Error, int> ValidateInt(string value) =>
ValidateAllDigits(value).Map(_ => int.Parse(value));
static Validation<Error, string> ValidateLength(string value, int length) =>
ValidateLength(value.AsEnumerableM(), length)
.Map(_ => value);
static Validation<Error, K<F, A>> ValidateLength<F, A>(K<F, A> fa, int length)
where F : Foldable<F> =>
fa.Count() == length
? Pure(fa)
: Fail(Error.New($"expected length to be {length}, but got: {fa.Count()}"));
static Validation<Error, int> CharToDigit(char ch) =>
ch is >= '0' and <= '9'
? Pure(ch - '0')
: Fail(Error.New($"expected a digit, but got: {ch}"));
static Validation<Error, Seq<int>> ValidateLuhn(Seq<int> digits)
{
int checkDigit = 0;
for (int i = digits.Length - 2; i >= 0; --i)
{
checkDigit += ((i & 1) is 0) switch
{
true => digits[i] > 4 ? digits[i] * 2 - 9 : digits[i] * 2,
false => digits[i]
};
}
return (10 - checkDigit % 10) % 10 == digits.Last
? Pure(digits)
: Fail(Error.New("invalid card number"));
}
}
@louthy
Copy link
Author

louthy commented Sep 15, 2024

@iblazhko Thanks for pointing this out. I have moved this into Samples/CreditCardValidation in the main repo and updated it. I've also updated the blog article that pointed to this gist to point to the repo instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment