Last active
September 15, 2024 20:38
-
-
Save louthy/09d949f0a62a0061989b64dd4903167f to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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")); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@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.