// Copy into GMT's backend. Requires Newtonsoft.Json and System.Net.Http. // This replaces legacy registration/import/balance calls, not GMT's controllers or local login. using System; using System.Net.Http; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace Indisty.Helper { public sealed class MiCash360Options { public string UrlHost { get; set; } public string CompanyUuid { get; set; } public string ApiKey { get; set; } } public sealed class MiCashOnboardingRecord { public Guid Id { get; set; } public string ExternalReference { get; set; } public string Status { get; set; } public int? WalletId { get; set; } } public sealed class MiCashCustomer { public string EId { get; set; } public Guid AccountUuid { get; set; } public string Application { get; set; } public string DisplayName { get; set; } public string FirstNames { get; set; } public string Surname { get; set; } public string MobileNumber { get; set; } public string Email { get; set; } } public sealed class MiCashWalletEntry { public int Id { get; set; } public string Name { get; set; } public decimal AvailableBalance { get; set; } } public sealed class MiCashAppWallet { public string Application { get; set; } public string Currency { get; set; } public decimal AvailableBalance { get; set; } public MiCashWalletEntry Wallet { get; set; } } public sealed class MiCashResult { public bool Success { get; set; } public string Status { get; set; } public string Application { get; set; } public MiCashOnboardingRecord Onboarding { get; set; } public MiCashCustomer Customer { get; set; } public MiCashAppWallet Wallet { get; set; } [JsonIgnore] public int HttpStatus { get; internal set; } } public sealed class MiCashApiException : Exception { public int HttpStatus { get; private set; } public string Error { get; private set; } public MiCashOnboardingRecord Onboarding { get; private set; } public MiCashApiException(int status, string error, MiCashOnboardingRecord onboarding = null) : base(error) { HttpStatus = status; Error = error; Onboarding = onboarding; } } public sealed class MiCashBankQuote { public Guid OnboardingId { get; internal set; } public Guid QuoteId { get; internal set; } public decimal Amount { get; internal set; } public decimal BankAmount { get; internal set; } public decimal ProviderFee { get; internal set; } public decimal LocalFee { get; internal set; } public string ExpiresAt { get; internal set; } // Persist in a protected backend quote store. Never return this token to the browser. [JsonIgnore] internal string WalletSession { get; set; } } public sealed class MiCash360Service : IDisposable { private readonly HttpClient client; private readonly string urlHost, companyUuid, apiKey; private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings { DateParseHandling = DateParseHandling.None, NullValueHandling = NullValueHandling.Ignore, MaxDepth = 20 }; public MiCash360Service(MiCash360Options options, HttpMessageHandler transport = null) { Uri host; Guid company; if (options == null || !Uri.TryCreate(options.UrlHost, UriKind.Absolute, out host) || host.Scheme != "https" || host.UserInfo != "" || host.Query != "" || host.Fragment != "" || host.AbsolutePath != "/") throw new ArgumentException("urlHost must be a MiCash HTTPS origin."); if (!Guid.TryParse(options.CompanyUuid, out company) || company == Guid.Empty) throw new ArgumentException("companyUuid is required."); if (string.IsNullOrWhiteSpace(options.ApiKey) || options.ApiKey.Length > 256 || Regex.IsMatch(options.ApiKey, @"[\r\n]")) throw new ArgumentException("apiKey is required."); urlHost = host.AbsoluteUri.TrimEnd('/'); companyUuid = company.ToString("D"); apiKey = options.ApiKey.Trim(); client = new HttpClient(transport ?? new HttpClientHandler { UseCookies = false, AllowAutoRedirect = false }); client.Timeout = TimeSpan.FromSeconds(45); } private static string Id(Guid value) { if (value == Guid.Empty) throw new ArgumentException("Use the saved onboardingId."); return value.ToString("D"); } private static Guid ReturnedId(JToken value) { Guid id; if(value == null || !Guid.TryParse((string)value,out id) || id == Guid.Empty) throw new MiCashApiException(502,"MICASH_RESPONSE_INVALID"); return id; } private static void Pin(string value) { if (value == null || !Regex.IsMatch(value, @"\A[0-9]{4,5}\z")) throw new ArgumentException("A customer PIN is required."); } private static void Otp(string value) { if (value == null || !Regex.IsMatch(value, @"\A[0-9]{5}\z")) throw new ArgumentException("A five-digit OTP is required."); } private static void Reference(string value) { if (string.IsNullOrWhiteSpace(value) || value.Length > 120 || value != value.Trim() || Regex.IsMatch(value, @"[\x00-\x1F\x7F]")) throw new ArgumentException("A stable externalReference is required."); } private static void Mobile(string value) { if (value == null || !Regex.IsMatch(value, @"\A\+[1-9][0-9]{7,14}\z")) throw new ArgumentException("Use an international mobile number, e.g. +27...."); } private async Task Send(HttpMethod method, string path, object body, CancellationToken cancellation, string walletSession = null) { var separator = path.Contains("?") ? "&" : "?"; using (var request = new HttpRequestMessage(method, urlHost + path + separator + "companyUuid=" + companyUuid)) { request.Headers.Add("X-Integrator-Key", apiKey); request.Headers.Accept.ParseAdd("application/json"); if (walletSession != null) request.Headers.Add("X-Wallet-Session", walletSession); if (body != null) request.Content = new StringContent(JsonConvert.SerializeObject(body, JsonSettings), Encoding.UTF8, "application/json"); try { using (var response = await client.SendAsync(request, cancellation).ConfigureAwait(false)) { var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false); JObject payload; try { payload = JsonConvert.DeserializeObject(text, JsonSettings); } catch (JsonException) { throw new MiCashApiException((int)response.StatusCode, "MICASH_NON_JSON_RESPONSE"); } if (payload == null) throw new MiCashApiException((int)response.StatusCode, "MICASH_NON_JSON_RESPONSE"); // Even an expected conflict must belong to GMT, not a mistakenly supplied MiCash key. foreach(var application in new[]{(string)payload["application"],(string)payload.SelectToken("customer.application"),(string)payload.SelectToken("wallet.application")}) if (application != null && application != "GMT") throw new MiCashApiException(502, "GMT_APPLICATION_CONFIGURATION_REQUIRED"); if (!response.IsSuccessStatusCode || payload.Value("success") != true) { var error = (string)payload["error"]; if (error == null || !Regex.IsMatch(error, @"\A[A-Z0-9_]{1,120}\z")) error = "MICASH_API_ERROR"; var record = payload["onboarding"] == null || payload["onboarding"].Type == JTokenType.Null ? null : payload["onboarding"].ToObject(); throw new MiCashApiException((int)response.StatusCode, error, record); } payload["httpStatus"] = (int)response.StatusCode; return payload; } } catch (HttpRequestException) { throw new MiCashApiException(503, "MICASH_REQUEST_UNCONFIRMED"); } catch (TaskCanceledException) { if (cancellation.IsCancellationRequested) throw; throw new MiCashApiException(503, "MICASH_REQUEST_UNCONFIRMED"); } } } private async Task Individual(HttpMethod method, Guid? id, string suffix, object body, CancellationToken cancellation) { var payload = await Send(method, "/api/v1/individual/onboardings" + (id.HasValue ? "/" + Id(id.Value) : "") + suffix, body, cancellation).ConfigureAwait(false); var application = (string)payload["application"] ?? (string)payload.SelectToken("customer.application") ?? (string)payload.SelectToken("wallet.application"); if (application != "GMT") throw new MiCashApiException(502, "GMT_APPLICATION_CONFIGURATION_REQUIRED"); if (payload.SelectToken("wallet.wallets") is JArray && ((JArray)payload.SelectToken("wallet.wallets")).Count > 0) throw new MiCashApiException(502, "GMT_WALLET_SCOPE_INVALID"); MiCashResult result; try { result = payload.ToObject(); } catch (JsonException) { throw new MiCashApiException(502, "MICASH_RESPONSE_INVALID"); } result.HttpStatus = payload.Value("httpStatus"); if (result.Customer != null && (string.IsNullOrWhiteSpace(result.Customer.EId) || result.Customer.AccountUuid == Guid.Empty)) throw new MiCashApiException(502, "MICASH_CUSTOMER_RESPONSE_INVALID"); if (result.Onboarding != null && result.Onboarding.Id == Guid.Empty) throw new MiCashApiException(502, "MICASH_ONBOARDING_RESPONSE_INVALID"); if (result.Wallet != null && (payload.SelectToken("wallet.availableBalance") == null || payload.SelectToken("wallet.wallet.availableBalance") == null || result.Wallet.Currency != "ZAR" || result.Wallet.Wallet == null || result.Wallet.Wallet.Id <= 0)) throw new MiCashApiException(502, "MICASH_WALLET_RESPONSE_INVALID"); if(result.Wallet != null && result.Wallet.AvailableBalance != result.Wallet.Wallet.AvailableBalance) throw new MiCashApiException(502,"GMT_WALLET_SCOPE_INVALID"); if (suffix == "/completion") { if(result.Customer == null || result.Wallet == null) throw new MiCashApiException(502,"MICASH_CUSTOMER_RESPONSE_INVALID"); result.Onboarding = new MiCashOnboardingRecord { Id = id.Value, Status = "ACTIVE", WalletId = result.Wallet.Wallet.Id }; } return result; } public Task StartRegistration(string externalReference, string mobileNumber, string email, bool termsAccepted, CancellationToken cancellation = default(CancellationToken)) { if (!string.IsNullOrEmpty(externalReference)) Reference(externalReference); Mobile(mobileNumber); if (!termsAccepted) throw new ArgumentException("Customer terms must be accepted."); return Individual(HttpMethod.Post, null, "", new { externalReference, mobileNumber, email, termsAccepted }, cancellation); } public Task VerifyRegisterOtp(Guid onboardingId, string otp, CancellationToken cancellation = default(CancellationToken)) { Otp(otp); return Individual(HttpMethod.Post, onboardingId, "/otp-verification", new { otp }, cancellation); } public Task ValidateProfile(Guid onboardingId, JObject profile, CancellationToken cancellation = default(CancellationToken)) { if(profile == null) throw new ArgumentNullException("profile"); return Individual(HttpMethod.Post, onboardingId, "/profile-validation", profile, cancellation); } public Task Register(Guid onboardingId, JObject profile, CancellationToken cancellation = default(CancellationToken)) { if(profile == null) throw new ArgumentNullException("profile"); return Individual(HttpMethod.Post, onboardingId, "/profile", profile, cancellation); } public Task RequestWalletPinOtp(Guid onboardingId, CancellationToken cancellation = default(CancellationToken)) { return Individual(HttpMethod.Post, onboardingId, "/wallet-pin/otp", new { }, cancellation); } public Task SetWalletPin(Guid onboardingId, string pinOtp, string pin, CancellationToken cancellation = default(CancellationToken)) { Otp(pinOtp); Pin(pin); return Individual(HttpMethod.Post, onboardingId, "/wallet-pin", new { otp = pinOtp, pin }, cancellation); } public Task CompleteRegistration(Guid onboardingId, string pin, bool termsAccepted, CancellationToken cancellation = default(CancellationToken)) { Pin(pin); if(!termsAccepted) throw new ArgumentException("Customer terms must be accepted."); return Individual(HttpMethod.Post, onboardingId, "/completion", new { pin, termsAccepted }, cancellation); } public Task CheckRegistration(Guid onboardingId, CancellationToken cancellation = default(CancellationToken)) { return Individual(HttpMethod.Get, onboardingId, "", null, cancellation); } public Task ResumeRegistration(Guid onboardingId, string mobileNumber, bool resendOtp = false, CancellationToken cancellation = default(CancellationToken)) { Mobile(mobileNumber); return Individual(HttpMethod.Post, onboardingId, "/resume", new { mobileNumber, resendOtp }, cancellation); } public Task LinkExistingCustomer(Guid onboardingId, string mobileNumber, string pin, CancellationToken cancellation = default(CancellationToken)) { Mobile(mobileNumber); Pin(pin); return Individual(HttpMethod.Post, onboardingId, "/profile-link", new { mobileNumber, pin }, cancellation); } public async Task ImportData(string externalReference, string mobileNumber, string email, string pin, bool termsAccepted, CancellationToken cancellation = default(CancellationToken)) { Pin(pin); Guid id; try { var start = await StartRegistration(externalReference, mobileNumber, email, termsAccepted, cancellation).ConfigureAwait(false); if(start.Onboarding == null || (start.Onboarding.Status != "ACTIVE" && start.Onboarding.Status != "PROFILE_CREATED" && start.Onboarding.Status != "EXISTING_PROFILE_REQUIRES_LINKING")) throw new MiCashApiException(409, "EXISTING_CUSTOMER_REQUIRED", start.Onboarding); id = start.Onboarding.Id; } catch (MiCashApiException error) { if(error.Error != "EXISTING_PROFILE_REQUIRES_LINKING" || error.Onboarding == null) throw; id = error.Onboarding.Id; } await LinkExistingCustomer(id, mobileNumber, pin, cancellation).ConfigureAwait(false); var complete = await CompleteRegistration(id, pin, termsAccepted, cancellation).ConfigureAwait(false); // Completion returns identity and wallet, not the onboarding record; retain the saved ID for GMT. complete.Onboarding = new MiCashOnboardingRecord { Id = id, ExternalReference = externalReference, Status = "ACTIVE", WalletId = complete.Wallet == null || complete.Wallet.Wallet == null ? (int?)null : complete.Wallet.Wallet.Id }; return complete; } public async Task GetBalance(Guid onboardingId, CancellationToken cancellation = default(CancellationToken)) { var result = await Individual(HttpMethod.Get, onboardingId, "/wallet", null, cancellation).ConfigureAwait(false); if(result.Wallet == null || result.Wallet.Wallet == null || result.Wallet.Wallet.Id <= 0) throw new MiCashApiException(502,"MICASH_WALLET_RESPONSE_INVALID"); return result.Wallet; } public async Task ListWallets(int page = 1, int pageSize = 25, CancellationToken cancellation = default(CancellationToken)) { if(page < 1 || page > 1000000 || pageSize < 1 || pageSize > 100) throw new ArgumentException("Invalid pagination."); var result=await Send(HttpMethod.Get,"/api/v1/integrator/wallets?page="+page+"&pageSize="+pageSize,null,cancellation).ConfigureAwait(false); var wallets=result["wallets"] as JArray; if(wallets==null)throw new MiCashApiException(502,"MICASH_WALLET_RESPONSE_INVALID"); // The legacy listing lacks an application label; verify rows against the app-scoped endpoint. foreach(var wallet in wallets) await CheckRegistration(ReturnedId(wallet["onboardingId"]),cancellation).ConfigureAwait(false); return result; } public async Task FindByReference(string externalReference, CancellationToken cancellation = default(CancellationToken)) { Reference(externalReference); var result=await Send(HttpMethod.Get,"/api/v1/integrator/onboardings/by-reference?externalReference="+Uri.EscapeDataString(externalReference),null,cancellation).ConfigureAwait(false); if(result["onboarding"]==null)throw new MiCashApiException(502,"MICASH_ONBOARDING_RESPONSE_INVALID"); await CheckRegistration(ReturnedId(result["onboarding"]["id"]),cancellation).ConfigureAwait(false); return result; } public async Task UnlinkBeneficiary(Guid onboardingId, CancellationToken cancellation = default(CancellationToken)) { await CheckRegistration(onboardingId,cancellation).ConfigureAwait(false); return await Send(HttpMethod.Post,"/api/v1/integrator/onboardings/"+Id(onboardingId)+"/unlink",null,cancellation).ConfigureAwait(false); } public async Task QuoteBankCashout(Guid onboardingId,string pin,decimal amount,string accountName,string accountNumber,string branchCode,CancellationToken cancellation = default(CancellationToken)) { Pin(pin); if(amount <= 0 || decimal.Round(amount,2)!=amount)throw new ArgumentException("Use a positive ZAR amount with at most two decimals."); await GetBalance(onboardingId,cancellation).ConfigureAwait(false); var path="/api/v1/integrator/onboardings/"+Id(onboardingId); var login=await Send(HttpMethod.Post,path+"/wallet-sessions",new{pin},cancellation).ConfigureAwait(false); var session=(string)login["walletSession"]; if(session==null||!Regex.IsMatch(session,@"\A[0-9a-fA-F]{64}\z"))throw new MiCashApiException(502,"MICASH_WALLET_SESSION_INVALID"); var result=await Send(HttpMethod.Post,path+"/bank-cashout-quotes",new{amount,accountName,accountNumber,branchCode},cancellation,session).ConfigureAwait(false); var quote=result["quote"] as JObject; if(quote==null)throw new MiCashApiException(502,"MICASH_QUOTE_INVALID"); var receipt=new MiCashBankQuote { OnboardingId=onboardingId,QuoteId=ReturnedId(quote["id"]),Amount=quote.Value("amount"),BankAmount=quote.Value("bankAmount"),ProviderFee=quote.Value("providerFee"),LocalFee=quote.Value("localFee"),ExpiresAt=(string)quote["expiresAt"],WalletSession=session }; if(receipt.Amount!=amount || receipt.BankAmount<=0 || receipt.ProviderFee<0 || receipt.LocalFee<0 || receipt.BankAmount+receipt.ProviderFee+receipt.LocalFee!=receipt.Amount || string.IsNullOrWhiteSpace(receipt.ExpiresAt)) throw new MiCashApiException(502,"MICASH_QUOTE_INVALID"); return receipt; } public Task SubmitBankCashout(MiCashBankQuote quote,string externalReference,bool customerConfirmed,CancellationToken cancellation = default(CancellationToken)) { Reference(externalReference); if(quote==null||quote.QuoteId==Guid.Empty||!customerConfirmed||string.IsNullOrWhiteSpace(quote.WalletSession))throw new ArgumentException("A saved backend quote and customer confirmation are required."); return Send(HttpMethod.Post,"/api/v1/integrator/onboardings/"+Id(quote.OnboardingId)+"/bank-cashouts",new{quoteId=quote.QuoteId,externalReference},cancellation,quote.WalletSession); } public async Task CheckBankCashout(Guid onboardingId,string externalReference,CancellationToken cancellation = default(CancellationToken)) { Reference(externalReference);await CheckRegistration(onboardingId,cancellation).ConfigureAwait(false); return await Send(HttpMethod.Get,"/api/v1/integrator/onboardings/"+Id(onboardingId)+"/bank-cashouts/by-reference?externalReference="+Uri.EscapeDataString(externalReference),null,cancellation).ConfigureAwait(false); } public void Dispose() { client.Dispose(); } } }