From 3d5f4e8af92e66f22305119ad7096f75f2d0e06b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 18:10:19 +0000 Subject: [PATCH 1/5] Initial plan From 63ff30688277e2e0aede4720b317c7ad727472f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 18:29:11 +0000 Subject: [PATCH 2/5] feat: add mobile wallet test api host Agent-Logs-Url: https://github.com/TransactionProcessing/TestHosts/sessions/df06e132-bede-4242-b612-b6451b43b5d2 Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- README.md | 14 + .../Controllers/MobileWalletController.cs | 855 ++++++++++++++++++ .../MobileWallet/MobileWalletContext.cs | 179 ++++ TestHosts/TestHosts/Program.cs | 12 +- .../MobileWalletBackgroundService.cs | 38 + .../MobileWallet/MobileWalletService.cs | 437 +++++++++ TestHosts/TestHosts/Startup.cs | 10 +- TestHosts/TestHosts/appsettings.json | 10 +- .../TestHosts/appsettings.preproduction.json | 12 +- TestHosts/TestHosts/appsettings.staging.json | 12 +- 10 files changed, 1572 insertions(+), 7 deletions(-) create mode 100644 TestHosts/TestHosts/Controllers/MobileWalletController.cs create mode 100644 TestHosts/TestHosts/Database/MobileWallet/MobileWalletContext.cs create mode 100644 TestHosts/TestHosts/Services/MobileWallet/MobileWalletBackgroundService.cs create mode 100644 TestHosts/TestHosts/Services/MobileWallet/MobileWalletService.cs diff --git a/README.md b/README.md index 72d2174..c75ffa4 100644 --- a/README.md +++ b/README.md @@ -1 +1,15 @@ # TestHosts + +## Mobile Wallet test API + +The `TestHosts` web application now includes a GSMA-style mobile wallet test API under `mobilemoney/v1_0` with: + +- OAuth2 client-credentials token issuance via `POST /oauth/token` +- account creation, lookup, status, balance, and transaction history endpoints +- idempotent transaction and reversal creation using the `X-Idempotency-Key` header +- asynchronous transaction and reversal completion via a background processor +- webhook subscriptions plus per-request callback URLs for event delivery +- audit entries for token, account, transaction, reversal, and webhook operations +- SQL Server or in-memory persistence using the `MobileWalletReadModel` connection string + +Default seeded OAuth2 client credentials are configured in `appsettings*.json` under `MobileWallet:OAuth`. diff --git a/TestHosts/TestHosts/Controllers/MobileWalletController.cs b/TestHosts/TestHosts/Controllers/MobileWalletController.cs new file mode 100644 index 0000000..9a491b4 --- /dev/null +++ b/TestHosts/TestHosts/Controllers/MobileWalletController.cs @@ -0,0 +1,855 @@ +namespace TestHosts.Controllers +{ + using Microsoft.AspNetCore.Mvc; + using Microsoft.EntityFrameworkCore; + using Newtonsoft.Json; + using System; + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using TestHosts.Database.MobileWallet; + using TestHosts.Services.MobileWallet; + + [Route("mobilemoney/v1_0")] + [ApiController] + public sealed class MobileWalletController : ControllerBase + { + private readonly MobileWalletContext Context; + private readonly MobileWalletService Service; + + public MobileWalletController(MobileWalletContext context, MobileWalletService service) + { + this.Context = context; + this.Service = service; + } + + [HttpPost("~/oauth/token")] + public async Task IssueToken([FromForm] MobileWalletTokenRequest request, CancellationToken cancellationToken) + { + if (request.grant_type != "client_credentials") + { + return this.BadRequest(new { error = "unsupported_grant_type" }); + } + + MobileWalletAccessToken? token = await this.Service.IssueTokenAsync(request.client_id, request.client_secret, cancellationToken); + if (token == null) + { + return this.Unauthorized(new { error = "invalid_client" }); + } + + var response = new + { + access_token = token.Token, + token_type = "Bearer", + expires_in = Math.Max(0, Convert.ToInt32((token.ExpiresAt - DateTime.UtcNow).TotalSeconds)) + }; + + await this.Service.RecordAuditAsync("oauth_token", + token.TokenId.ToString("N"), + "issue", + request.client_id, + this.HttpContext, + null, + JsonConvert.SerializeObject(request), + JsonConvert.SerializeObject(response), + cancellationToken); + + return this.Ok(response); + } + + [HttpPost("accounts")] + public async Task CreateAccount([FromBody] CreateMobileWalletAccountRequest request, CancellationToken cancellationToken) + { + (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + if (authResult.errorResult != null) + { + return authResult.errorResult; + } + + IActionResult? validationResult = this.ValidateAccountRequest(request); + if (validationResult != null) + { + return validationResult; + } + + if (await this.Context.Accounts.AnyAsync(a => a.AccountReference == request.accountReference, cancellationToken)) + { + return this.Conflict(new { message = "An account with the same reference already exists." }); + } + + MobileWalletAccount account = new() + { + AccountReference = request.accountReference, + AccountType = request.accountType ?? "wallet", + Status = request.accountStatus ?? "active", + Currency = request.currency, + AvailableBalance = request.initialBalance ?? 0, + GivenName = request.kycInformation?.givenName ?? String.Empty, + FamilyName = request.kycInformation?.familyName ?? String.Empty, + Msisdn = request.msisdn, + EmailAddress = request.emailAddress, + KycStatus = request.kycInformation?.kycStatus ?? "not_provided", + IdentityType = request.kycInformation?.identity?.idDocument?.idType, + IdentityNumber = request.kycInformation?.identity?.idDocument?.idNumber, + DateOfBirth = request.kycInformation?.dateOfBirth, + Nationality = request.kycInformation?.nationality, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + await this.Context.Accounts.AddAsync(account, cancellationToken); + await this.Context.SaveChangesAsync(cancellationToken); + + var response = this.MapAccount(account); + await this.Service.RecordAuditAsync("account", + account.AccountReference, + "create", + authResult.client.ClientId, + this.HttpContext, + null, + JsonConvert.SerializeObject(request), + JsonConvert.SerializeObject(response), + cancellationToken); + + return this.CreatedAtAction(nameof(GetAccount), new { accountReference = account.AccountReference }, response); + } + + [HttpGet("accounts/{accountReference}")] + public async Task GetAccount(String accountReference, CancellationToken cancellationToken) + { + (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + if (authResult.errorResult != null) + { + return authResult.errorResult; + } + + MobileWalletAccount? account = await this.Context.Accounts.SingleOrDefaultAsync(a => a.AccountReference == accountReference, cancellationToken); + if (account == null) + { + return this.NotFound(); + } + + var response = this.MapAccount(account); + await this.Service.RecordAuditAsync("account", + account.AccountReference, + "view", + authResult.client.ClientId, + this.HttpContext, + null, + null, + JsonConvert.SerializeObject(response), + cancellationToken); + + return this.Ok(response); + } + + [HttpGet("accounts/{accountReference}/balance")] + public async Task GetAccountBalance(String accountReference, CancellationToken cancellationToken) + { + (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + if (authResult.errorResult != null) + { + return authResult.errorResult; + } + + MobileWalletAccount? account = await this.Context.Accounts.SingleOrDefaultAsync(a => a.AccountReference == accountReference, cancellationToken); + if (account == null) + { + return this.NotFound(); + } + + var response = new + { + accountReference = account.AccountReference, + availableBalance = account.AvailableBalance, + currency = account.Currency + }; + + await this.Service.RecordAuditAsync("account_balance", + account.AccountReference, + "view", + authResult.client.ClientId, + this.HttpContext, + null, + null, + JsonConvert.SerializeObject(response), + cancellationToken); + + return this.Ok(response); + } + + [HttpGet("accounts/{accountReference}/status")] + public async Task GetAccountStatus(String accountReference, CancellationToken cancellationToken) + { + (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + if (authResult.errorResult != null) + { + return authResult.errorResult; + } + + MobileWalletAccount? account = await this.Context.Accounts.SingleOrDefaultAsync(a => a.AccountReference == accountReference, cancellationToken); + if (account == null) + { + return this.NotFound(); + } + + var response = new + { + accountReference = account.AccountReference, + accountStatus = account.Status + }; + + await this.Service.RecordAuditAsync("account_status", + account.AccountReference, + "view", + authResult.client.ClientId, + this.HttpContext, + null, + null, + JsonConvert.SerializeObject(response), + cancellationToken); + + return this.Ok(response); + } + + [HttpGet("accounts/{accountReference}/transactions")] + public async Task GetAccountTransactions(String accountReference, CancellationToken cancellationToken) + { + (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + if (authResult.errorResult != null) + { + return authResult.errorResult; + } + + if (await this.Context.Accounts.AnyAsync(a => a.AccountReference == accountReference, cancellationToken) == false) + { + return this.NotFound(); + } + + var transactions = await this.Context.Transactions.Where(t => t.DebitAccountReference == accountReference || t.CreditAccountReference == accountReference) + .OrderByDescending(t => t.CreatedAt) + .Select(t => new + { + transactionReference = t.TransactionReference, + transactionType = t.TransactionType, + transactionStatus = t.Status, + amount = t.Amount, + currency = t.Currency, + createdAt = t.CreatedAt, + debitAccountReference = t.DebitAccountReference, + creditAccountReference = t.CreditAccountReference + }) + .ToListAsync(cancellationToken); + + List response = transactions.Cast().ToList(); + + await this.Service.RecordAuditAsync("account_transactions", + accountReference, + "list", + authResult.client.ClientId, + this.HttpContext, + null, + null, + JsonConvert.SerializeObject(response), + cancellationToken); + + return this.Ok(response); + } + + [HttpPost("transactions/type/{transactionType}")] + public async Task CreateTransaction(String transactionType, [FromBody] CreateMobileWalletTransactionRequest request, CancellationToken cancellationToken) + { + (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + if (authResult.errorResult != null) + { + return authResult.errorResult; + } + + IActionResult? validationResult = this.ValidateTransactionRequest(request, transactionType); + if (validationResult != null) + { + return validationResult; + } + + String idempotencyKey = this.GetRequiredIdempotencyKey(); + if (String.IsNullOrWhiteSpace(idempotencyKey)) + { + return this.BadRequest(new { message = "X-Idempotency-Key header is required." }); + } + + String requestPayload = JsonConvert.SerializeObject(request); + MobileWalletTransaction? existingTransaction = await this.Context.Transactions.SingleOrDefaultAsync(t => t.ClientId == authResult.client.ClientId && + t.IdempotencyKey == idempotencyKey, + cancellationToken); + + if (existingTransaction != null) + { + if (existingTransaction.RequestPayload != requestPayload) + { + return this.Conflict(new { message = "Idempotency key has already been used with a different request payload." }); + } + + return this.Accepted(this.MapTransaction(existingTransaction)); + } + + MobileWalletTransaction transaction = new() + { + TransactionReference = Guid.NewGuid().ToString("N"), + ClientId = authResult.client.ClientId, + TransactionType = transactionType, + Amount = request.amount, + Currency = request.currency, + DebitAccountReference = request.debitParty.accountReference, + CreditAccountReference = request.creditParty.accountReference, + ExternalReference = request.requestingOrganisationTransactionReference, + IdempotencyKey = idempotencyKey, + RequestPayload = requestPayload, + CallbackUrl = request.callbackUrl, + MetadataJson = request.metadata == null ? null : JsonConvert.SerializeObject(request.metadata), + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + await this.Context.Transactions.AddAsync(transaction, cancellationToken); + await this.Context.SaveChangesAsync(cancellationToken); + + var response = this.MapTransaction(transaction); + await this.Service.RecordAuditAsync("transaction", + transaction.TransactionReference, + "create", + authResult.client.ClientId, + this.HttpContext, + idempotencyKey, + requestPayload, + JsonConvert.SerializeObject(response), + cancellationToken); + + return this.Accepted(response); + } + + [HttpGet("transactions/{transactionReference}")] + public async Task GetTransaction(String transactionReference, CancellationToken cancellationToken) + { + (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + if (authResult.errorResult != null) + { + return authResult.errorResult; + } + + MobileWalletTransaction? transaction = await this.Context.Transactions.SingleOrDefaultAsync(t => t.TransactionReference == transactionReference, cancellationToken); + if (transaction == null) + { + return this.NotFound(); + } + + var response = this.MapTransaction(transaction); + await this.Service.RecordAuditAsync("transaction", + transaction.TransactionReference, + "view", + authResult.client.ClientId, + this.HttpContext, + null, + null, + JsonConvert.SerializeObject(response), + cancellationToken); + + return this.Ok(response); + } + + [HttpPatch("transactions/{transactionReference}")] + public async Task UpdateTransaction(String transactionReference, [FromBody] UpdateMobileWalletTransactionRequest request, CancellationToken cancellationToken) + { + (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + if (authResult.errorResult != null) + { + return authResult.errorResult; + } + + MobileWalletTransaction? transaction = await this.Context.Transactions.SingleOrDefaultAsync(t => t.TransactionReference == transactionReference, cancellationToken); + if (transaction == null) + { + return this.NotFound(); + } + + if (request.transactionStatus is not ("pending" or "completed" or "failed" or "rejected")) + { + return this.BadRequest(new { message = "transactionStatus must be one of pending, completed, failed or rejected." }); + } + + if (transaction.Status != "pending" && transaction.Status != request.transactionStatus) + { + return this.Conflict(new { message = "Only pending transactions can be updated." }); + } + + if (request.transactionStatus == "pending") + { + transaction.StatusMessage = request.statusMessage ?? transaction.StatusMessage; + transaction.UpdatedAt = DateTime.UtcNow; + await this.Context.SaveChangesAsync(cancellationToken); + } + else + { + await this.Service.FinalizeTransactionAsync(transaction, request.transactionStatus, request.statusMessage, cancellationToken); + } + + var response = this.MapTransaction(transaction); + await this.Service.RecordAuditAsync("transaction", + transaction.TransactionReference, + "update", + authResult.client.ClientId, + this.HttpContext, + null, + JsonConvert.SerializeObject(request), + JsonConvert.SerializeObject(response), + cancellationToken); + + return this.Ok(response); + } + + [HttpPost("reversals")] + public async Task CreateReversal([FromBody] CreateMobileWalletReversalRequest request, CancellationToken cancellationToken) + { + (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + if (authResult.errorResult != null) + { + return authResult.errorResult; + } + + if (String.IsNullOrWhiteSpace(request.originalTransactionReference) || String.IsNullOrWhiteSpace(request.reversalReason)) + { + return this.BadRequest(new { message = "originalTransactionReference and reversalReason are required." }); + } + + String idempotencyKey = this.GetRequiredIdempotencyKey(); + if (String.IsNullOrWhiteSpace(idempotencyKey)) + { + return this.BadRequest(new { message = "X-Idempotency-Key header is required." }); + } + + String requestPayload = JsonConvert.SerializeObject(request); + MobileWalletReversal? existingReversal = await this.Context.Reversals.SingleOrDefaultAsync(r => r.ClientId == authResult.client.ClientId && + r.IdempotencyKey == idempotencyKey, + cancellationToken); + if (existingReversal != null) + { + if (existingReversal.RequestPayload != requestPayload) + { + return this.Conflict(new { message = "Idempotency key has already been used with a different reversal request." }); + } + + return this.Accepted(this.MapReversal(existingReversal)); + } + + if (await this.Context.Transactions.AnyAsync(t => t.TransactionReference == request.originalTransactionReference, cancellationToken) == false) + { + return this.NotFound(new { message = "Original transaction not found." }); + } + + MobileWalletReversal reversal = new() + { + ReversalReference = Guid.NewGuid().ToString("N"), + ClientId = authResult.client.ClientId, + OriginalTransactionReference = request.originalTransactionReference, + Reason = request.reversalReason, + IdempotencyKey = idempotencyKey, + RequestPayload = requestPayload, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + await this.Context.Reversals.AddAsync(reversal, cancellationToken); + await this.Context.SaveChangesAsync(cancellationToken); + + var response = this.MapReversal(reversal); + await this.Service.RecordAuditAsync("reversal", + reversal.ReversalReference, + "create", + authResult.client.ClientId, + this.HttpContext, + idempotencyKey, + requestPayload, + JsonConvert.SerializeObject(response), + cancellationToken); + + return this.Accepted(response); + } + + [HttpGet("reversals/{reversalReference}")] + public async Task GetReversal(String reversalReference, CancellationToken cancellationToken) + { + (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + if (authResult.errorResult != null) + { + return authResult.errorResult; + } + + MobileWalletReversal? reversal = await this.Context.Reversals.SingleOrDefaultAsync(r => r.ReversalReference == reversalReference, cancellationToken); + if (reversal == null) + { + return this.NotFound(); + } + + var response = this.MapReversal(reversal); + await this.Service.RecordAuditAsync("reversal", + reversal.ReversalReference, + "view", + authResult.client.ClientId, + this.HttpContext, + null, + null, + JsonConvert.SerializeObject(response), + cancellationToken); + + return this.Ok(response); + } + + [HttpPost("webhooks/subscriptions")] + public async Task CreateWebhookSubscription([FromBody] CreateMobileWalletWebhookSubscriptionRequest request, CancellationToken cancellationToken) + { + (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + if (authResult.errorResult != null) + { + return authResult.errorResult; + } + + if (Uri.TryCreate(request.callbackUrl, UriKind.Absolute, out _) == false) + { + return this.BadRequest(new { message = "callbackUrl must be an absolute URL." }); + } + + MobileWalletWebhookSubscription subscription = new() + { + SubscriptionId = Guid.NewGuid(), + ClientId = authResult.client.ClientId, + EventType = String.IsNullOrWhiteSpace(request.eventType) ? "*" : request.eventType, + CallbackUrl = request.callbackUrl, + IsActive = true, + CreatedAt = DateTime.UtcNow + }; + + await this.Context.WebhookSubscriptions.AddAsync(subscription, cancellationToken); + await this.Context.SaveChangesAsync(cancellationToken); + + var response = new + { + subscriptionId = subscription.SubscriptionId, + eventType = subscription.EventType, + callbackUrl = subscription.CallbackUrl, + isActive = subscription.IsActive + }; + + await this.Service.RecordAuditAsync("webhook_subscription", + subscription.SubscriptionId.ToString("N"), + "create", + authResult.client.ClientId, + this.HttpContext, + null, + JsonConvert.SerializeObject(request), + JsonConvert.SerializeObject(response), + cancellationToken); + + return this.Ok(response); + } + + private async Task<(MobileWalletClient? client, IActionResult? errorResult)> RequireClientAsync(CancellationToken cancellationToken) + { + String authorizationHeader = this.Request.Headers.Authorization.ToString(); + String? bearerToken = authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? authorizationHeader["Bearer ".Length..].Trim() : null; + MobileWalletClient? client = await this.Service.AuthenticateAsync(bearerToken, cancellationToken); + if (client == null) + { + this.Response.Headers["WWW-Authenticate"] = "Bearer"; + return (null, this.Unauthorized(new { message = "A valid OAuth2 bearer token is required." })); + } + + return (client, null); + } + + private IActionResult? ValidateAccountRequest(CreateMobileWalletAccountRequest request) + { + if (String.IsNullOrWhiteSpace(request.accountReference)) + { + return this.BadRequest(new { message = "accountReference is required." }); + } + + if (this.IsValidCurrency(request.currency) == false) + { + return this.BadRequest(new { message = "currency must be a three-letter ISO currency code." }); + } + + if (request.initialBalance < 0) + { + return this.BadRequest(new { message = "initialBalance cannot be negative." }); + } + + if (request.kycInformation?.kycStatus == "verified" && + (String.IsNullOrWhiteSpace(request.kycInformation.givenName) || + String.IsNullOrWhiteSpace(request.kycInformation.familyName) || + String.IsNullOrWhiteSpace(request.kycInformation.identity?.idDocument?.idType) || + String.IsNullOrWhiteSpace(request.kycInformation.identity?.idDocument?.idNumber))) + { + return this.BadRequest(new { message = "Verified KYC accounts must include customer names and identity document details." }); + } + + return null; + } + + private IActionResult? ValidateTransactionRequest(CreateMobileWalletTransactionRequest request, String transactionType) + { + if (String.IsNullOrWhiteSpace(transactionType)) + { + return this.BadRequest(new { message = "transactionType is required." }); + } + + if (request.amount <= 0) + { + return this.BadRequest(new { message = "amount must be greater than zero." }); + } + + if (request.debitParty == null || String.IsNullOrWhiteSpace(request.debitParty.accountReference) || + request.creditParty == null || String.IsNullOrWhiteSpace(request.creditParty.accountReference)) + { + return this.BadRequest(new { message = "debitParty.accountReference and creditParty.accountReference are required." }); + } + + if (request.debitParty.accountReference == request.creditParty.accountReference) + { + return this.BadRequest(new { message = "debit and credit accounts must be different." }); + } + + if (this.IsValidCurrency(request.currency) == false) + { + return this.BadRequest(new { message = "currency must be a three-letter ISO currency code." }); + } + + if (String.IsNullOrWhiteSpace(request.callbackUrl) == false && + Uri.TryCreate(request.callbackUrl, UriKind.Absolute, out _) == false) + { + return this.BadRequest(new { message = "callbackUrl must be an absolute URL." }); + } + + return null; + } + + private String GetRequiredIdempotencyKey() + { + return this.Request.Headers.TryGetValue("X-Idempotency-Key", out var values) ? values.ToString().Trim() : String.Empty; + } + + private Boolean IsValidCurrency(String? currency) + { + return String.IsNullOrWhiteSpace(currency) == false && + currency.Length == 3 && + currency.All(Char.IsLetter); + } + + private Object MapAccount(MobileWalletAccount account) + { + return new + { + accountReference = account.AccountReference, + accountType = account.AccountType, + accountStatus = account.Status, + currency = account.Currency, + availableBalance = account.AvailableBalance, + msisdn = account.Msisdn, + emailAddress = account.EmailAddress, + kycInformation = new + { + givenName = account.GivenName, + familyName = account.FamilyName, + kycStatus = account.KycStatus, + nationality = account.Nationality, + dateOfBirth = account.DateOfBirth, + identity = String.IsNullOrWhiteSpace(account.IdentityType) || String.IsNullOrWhiteSpace(account.IdentityNumber) + ? null + : new + { + idDocument = new + { + idType = account.IdentityType, + idNumber = account.IdentityNumber + } + } + } + }; + } + + private Object MapTransaction(MobileWalletTransaction transaction) + { + return new + { + transactionReference = transaction.TransactionReference, + transactionType = transaction.TransactionType, + transactionStatus = transaction.Status, + statusMessage = transaction.StatusMessage, + amount = transaction.Amount, + currency = transaction.Currency, + debitParty = new { accountReference = transaction.DebitAccountReference }, + creditParty = new { accountReference = transaction.CreditAccountReference }, + requestingOrganisationTransactionReference = transaction.ExternalReference, + callbackUrl = transaction.CallbackUrl, + createdAt = transaction.CreatedAt, + completedAt = transaction.CompletedAt + }; + } + + private Object MapReversal(MobileWalletReversal reversal) + { + return new + { + reversalReference = reversal.ReversalReference, + originalTransactionReference = reversal.OriginalTransactionReference, + reversalStatus = reversal.Status, + statusMessage = reversal.StatusMessage, + reversalReason = reversal.Reason, + createdAt = reversal.CreatedAt, + completedAt = reversal.CompletedAt + }; + } + } + + public sealed class MobileWalletTokenRequest + { + [Required] + public String grant_type { get; set; } = String.Empty; + + [Required] + public String client_id { get; set; } = String.Empty; + + [Required] + public String client_secret { get; set; } = String.Empty; + } + + public sealed class CreateMobileWalletAccountRequest + { + [Required] + [JsonProperty("accountReference")] + public String accountReference { get; set; } = String.Empty; + + [Required] + [JsonProperty("currency")] + public String currency { get; set; } = String.Empty; + + [JsonProperty("accountType")] + public String? accountType { get; set; } + + [JsonProperty("accountStatus")] + public String? accountStatus { get; set; } + + [JsonProperty("initialBalance")] + public Decimal? initialBalance { get; set; } + + [JsonProperty("msisdn")] + public String? msisdn { get; set; } + + [JsonProperty("emailAddress")] + public String? emailAddress { get; set; } + + [JsonProperty("kycInformation")] + public MobileWalletKycInformation? kycInformation { get; set; } + } + + public sealed class CreateMobileWalletTransactionRequest + { + [JsonProperty("amount")] + public Decimal amount { get; set; } + + [Required] + [JsonProperty("currency")] + public String currency { get; set; } = String.Empty; + + [Required] + [JsonProperty("debitParty")] + public MobileWalletParty debitParty { get; set; } = new(); + + [Required] + [JsonProperty("creditParty")] + public MobileWalletParty creditParty { get; set; } = new(); + + [JsonProperty("requestingOrganisationTransactionReference")] + public String? requestingOrganisationTransactionReference { get; set; } + + [JsonProperty("callbackUrl")] + public String? callbackUrl { get; set; } + + [JsonProperty("metadata")] + public Dictionary? metadata { get; set; } + } + + public sealed class UpdateMobileWalletTransactionRequest + { + [Required] + [JsonProperty("transactionStatus")] + public String transactionStatus { get; set; } = String.Empty; + + [JsonProperty("statusMessage")] + public String? statusMessage { get; set; } + } + + public sealed class CreateMobileWalletReversalRequest + { + [Required] + [JsonProperty("originalTransactionReference")] + public String originalTransactionReference { get; set; } = String.Empty; + + [Required] + [JsonProperty("reversalReason")] + public String reversalReason { get; set; } = String.Empty; + } + + public sealed class CreateMobileWalletWebhookSubscriptionRequest + { + [Required] + [JsonProperty("callbackUrl")] + public String callbackUrl { get; set; } = String.Empty; + + [JsonProperty("eventType")] + public String? eventType { get; set; } + } + + public sealed class MobileWalletParty + { + [Required] + [JsonProperty("accountReference")] + public String accountReference { get; set; } = String.Empty; + } + + public sealed class MobileWalletKycInformation + { + [JsonProperty("givenName")] + public String? givenName { get; set; } + + [JsonProperty("familyName")] + public String? familyName { get; set; } + + [JsonProperty("kycStatus")] + public String? kycStatus { get; set; } + + [JsonProperty("dateOfBirth")] + public String? dateOfBirth { get; set; } + + [JsonProperty("nationality")] + public String? nationality { get; set; } + + [JsonProperty("identity")] + public MobileWalletIdentity? identity { get; set; } + } + + public sealed class MobileWalletIdentity + { + [JsonProperty("idDocument")] + public MobileWalletIdDocument? idDocument { get; set; } + } + + public sealed class MobileWalletIdDocument + { + [JsonProperty("idType")] + public String? idType { get; set; } + + [JsonProperty("idNumber")] + public String? idNumber { get; set; } + } +} diff --git a/TestHosts/TestHosts/Database/MobileWallet/MobileWalletContext.cs b/TestHosts/TestHosts/Database/MobileWallet/MobileWalletContext.cs new file mode 100644 index 0000000..192499a --- /dev/null +++ b/TestHosts/TestHosts/Database/MobileWallet/MobileWalletContext.cs @@ -0,0 +1,179 @@ +namespace TestHosts.Database.MobileWallet +{ + using Microsoft.EntityFrameworkCore; + using Shared.General; + using System; + + public class MobileWalletContext : DbContext + { + private readonly String ConnectionString; + + public MobileWalletContext() + { + this.ConnectionString = ConfigurationReader.GetConnectionString(Constants.MobileWalletReadModelConfig); + } + + public MobileWalletContext(String connectionString) + { + this.ConnectionString = connectionString; + } + + public MobileWalletContext(DbContextOptions dbContextOptions) : base(dbContextOptions) + { + } + + public DbSet Clients { get; set; } + public DbSet AccessTokens { get; set; } + public DbSet Accounts { get; set; } + public DbSet Transactions { get; set; } + public DbSet Reversals { get; set; } + public DbSet WebhookSubscriptions { get; set; } + public DbSet WebhookDeliveries { get; set; } + public DbSet AuditEntries { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (string.IsNullOrWhiteSpace(this.ConnectionString) == false) + { + optionsBuilder.UseSqlServer(this.ConnectionString); + } + + base.OnConfiguring(optionsBuilder); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(c => c.ClientId); + modelBuilder.Entity().HasKey(t => t.TokenId); + modelBuilder.Entity().HasIndex(t => t.Token).IsUnique(); + modelBuilder.Entity().HasKey(a => a.AccountReference); + modelBuilder.Entity().HasKey(t => t.TransactionReference); + modelBuilder.Entity().HasIndex(t => new { t.ClientId, t.IdempotencyKey }).IsUnique(); + modelBuilder.Entity().HasIndex(t => t.ExternalReference); + modelBuilder.Entity().HasKey(r => r.ReversalReference); + modelBuilder.Entity().HasIndex(r => new { r.ClientId, r.IdempotencyKey }).IsUnique(); + modelBuilder.Entity().HasKey(s => s.SubscriptionId); + modelBuilder.Entity().HasKey(d => d.DeliveryId); + modelBuilder.Entity().HasKey(a => a.AuditEntryId); + + base.OnModelCreating(modelBuilder); + } + } + + public sealed class MobileWalletClient + { + public String ClientId { get; set; } = String.Empty; + public String ClientSecret { get; set; } = String.Empty; + public String Name { get; set; } = String.Empty; + public String? CallbackUrl { get; set; } + public Boolean IsActive { get; set; } + public DateTime CreatedAt { get; set; } + } + + public sealed class MobileWalletAccessToken + { + public Guid TokenId { get; set; } + public String Token { get; set; } = String.Empty; + public String ClientId { get; set; } = String.Empty; + public DateTime ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } + } + + public sealed class MobileWalletAccount + { + public String AccountReference { get; set; } = String.Empty; + public String AccountType { get; set; } = "wallet"; + public String Status { get; set; } = "active"; + public String Currency { get; set; } = "USD"; + public Decimal AvailableBalance { get; set; } + public String GivenName { get; set; } = String.Empty; + public String FamilyName { get; set; } = String.Empty; + public String? Msisdn { get; set; } + public String? EmailAddress { get; set; } + public String KycStatus { get; set; } = "not_provided"; + public String? IdentityType { get; set; } + public String? IdentityNumber { get; set; } + public String? DateOfBirth { get; set; } + public String? Nationality { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } + + public sealed class MobileWalletTransaction + { + public String TransactionReference { get; set; } = String.Empty; + public String ClientId { get; set; } = String.Empty; + public String TransactionType { get; set; } = String.Empty; + public String Status { get; set; } = "pending"; + public String StatusMessage { get; set; } = "Transaction accepted for asynchronous processing."; + public Decimal Amount { get; set; } + public String Currency { get; set; } = "USD"; + public String DebitAccountReference { get; set; } = String.Empty; + public String CreditAccountReference { get; set; } = String.Empty; + public String? ExternalReference { get; set; } + public String IdempotencyKey { get; set; } = String.Empty; + public String RequestPayload { get; set; } = String.Empty; + public String? CallbackUrl { get; set; } + public String? MetadataJson { get; set; } + public Boolean BalanceApplied { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public DateTime? CompletedAt { get; set; } + } + + public sealed class MobileWalletReversal + { + public String ReversalReference { get; set; } = String.Empty; + public String ClientId { get; set; } = String.Empty; + public String OriginalTransactionReference { get; set; } = String.Empty; + public String Reason { get; set; } = String.Empty; + public String Status { get; set; } = "pending"; + public String StatusMessage { get; set; } = "Reversal accepted for asynchronous processing."; + public String IdempotencyKey { get; set; } = String.Empty; + public String RequestPayload { get; set; } = String.Empty; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public DateTime? CompletedAt { get; set; } + } + + public sealed class MobileWalletWebhookSubscription + { + public Guid SubscriptionId { get; set; } + public String ClientId { get; set; } = String.Empty; + public String EventType { get; set; } = "*"; + public String CallbackUrl { get; set; } = String.Empty; + public Boolean IsActive { get; set; } + public DateTime CreatedAt { get; set; } + } + + public sealed class MobileWalletWebhookDelivery + { + public Guid DeliveryId { get; set; } + public String ClientId { get; set; } = String.Empty; + public String EventType { get; set; } = String.Empty; + public String ResourceReference { get; set; } = String.Empty; + public String CallbackUrl { get; set; } = String.Empty; + public String Payload { get; set; } = String.Empty; + public String Status { get; set; } = "pending"; + public Int32 AttemptCount { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? LastAttemptAt { get; set; } + public DateTime? DeliveredAt { get; set; } + public String? LastError { get; set; } + } + + public sealed class MobileWalletAuditEntry + { + public Guid AuditEntryId { get; set; } + public String ResourceType { get; set; } = String.Empty; + public String ResourceReference { get; set; } = String.Empty; + public String Action { get; set; } = String.Empty; + public String Actor { get; set; } = String.Empty; + public String HttpMethod { get; set; } = String.Empty; + public String Path { get; set; } = String.Empty; + public String? IdempotencyKey { get; set; } + public String? RequestPayload { get; set; } + public String? ResponsePayload { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/TestHosts/TestHosts/Program.cs b/TestHosts/TestHosts/Program.cs index 74c9a62..e43847d 100644 --- a/TestHosts/TestHosts/Program.cs +++ b/TestHosts/TestHosts/Program.cs @@ -31,7 +31,9 @@ using TestHosts.Common; using TestHosts.Database.PataPawa; using TestHosts.Database.TestBank; +using TestHosts.Database.MobileWallet; using TestHosts.SoapServices; +using TestHosts.Services.MobileWallet; using ILogger = Microsoft.Extensions.Logging.ILogger; using LogLevel = Microsoft.Extensions.Logging.LogLevel; @@ -108,6 +110,7 @@ if (builder.Environment.IsEnvironment("IntegrationTest") || builder.Configuration.GetValue("ServiceOptions:UseInMemoryDatabase") == true) { builder.Services.AddDbContext(builder => builder.UseInMemoryDatabase(Constants.TestBankReadModelConfig)); builder.Services.AddDbContext(builder => builder.UseInMemoryDatabase(Constants.PataPawaReadModelConfig)); + builder.Services.AddDbContext(builder => builder.UseInMemoryDatabase(Constants.MobileWalletReadModelConfig)); } else { @@ -116,8 +119,13 @@ String pataPawaConnectionString = ConfigurationReader.GetConnectionString(Constants.PataPawaReadModelConfig); builder.Services.AddDbContext(builder => builder.UseSqlServer(pataPawaConnectionString)); + + String mobileWalletConnectionString = ConfigurationReader.GetConnectionString(Constants.MobileWalletReadModelConfig); + builder.Services.AddDbContext(builder => builder.UseSqlServer(mobileWalletConnectionString)); } + builder.Services.AddHttpClient(); + builder.Services.AddScoped(); builder.Services.AddScoped(x => new TenantContext()); builder.Services.AddSingleton(); // Add your background hosted service @@ -128,6 +136,7 @@ // Database initialization will now be handled by a hosted service builder.Services.AddHostedService(); + builder.Services.AddHostedService(); builder.Services.AddScoped(x => new TenantContext()); builder.Services.AddSingleton(); @@ -208,4 +217,5 @@ public static class Constants { public static readonly String PataPawaReadModelConfig = "PataPawaReadModel"; public static readonly String TestBankReadModelConfig = "TestBankReadModel"; -} \ No newline at end of file + public static readonly String MobileWalletReadModelConfig = "MobileWalletReadModel"; +} diff --git a/TestHosts/TestHosts/Services/MobileWallet/MobileWalletBackgroundService.cs b/TestHosts/TestHosts/Services/MobileWallet/MobileWalletBackgroundService.cs new file mode 100644 index 0000000..b8850c7 --- /dev/null +++ b/TestHosts/TestHosts/Services/MobileWallet/MobileWalletBackgroundService.cs @@ -0,0 +1,38 @@ +namespace TestHosts.Services.MobileWallet +{ + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using System; + using System.Threading; + using System.Threading.Tasks; + + public sealed class MobileWalletBackgroundService : BackgroundService + { + private readonly IServiceScopeFactory ScopeFactory; + + public MobileWalletBackgroundService(IServiceScopeFactory scopeFactory) + { + this.ScopeFactory = scopeFactory; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (stoppingToken.IsCancellationRequested == false) + { + try + { + using IServiceScope scope = this.ScopeFactory.CreateScope(); + MobileWalletService service = scope.ServiceProvider.GetRequiredService(); + await service.ProcessPendingTransactionsAsync(stoppingToken); + await service.ProcessPendingReversalsAsync(stoppingToken); + await service.DeliverPendingWebhooksAsync(stoppingToken); + } + catch + { + } + + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + } + } +} diff --git a/TestHosts/TestHosts/Services/MobileWallet/MobileWalletService.cs b/TestHosts/TestHosts/Services/MobileWallet/MobileWalletService.cs new file mode 100644 index 0000000..66e53d5 --- /dev/null +++ b/TestHosts/TestHosts/Services/MobileWallet/MobileWalletService.cs @@ -0,0 +1,437 @@ +namespace TestHosts.Services.MobileWallet +{ + using Microsoft.AspNetCore.Http; + using Microsoft.EntityFrameworkCore; + using Microsoft.Extensions.Configuration; + using Newtonsoft.Json; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Net.Http; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using TestHosts.Database.MobileWallet; + + public sealed class MobileWalletService + { + private readonly MobileWalletContext Context; + private readonly IConfiguration Configuration; + private readonly IHttpClientFactory HttpClientFactory; + + public MobileWalletService(MobileWalletContext context, IConfiguration configuration, IHttpClientFactory httpClientFactory) + { + this.Context = context; + this.Configuration = configuration; + this.HttpClientFactory = httpClientFactory; + } + + public async Task EnsureSeedDataAsync(CancellationToken cancellationToken) + { + var defaultClientId = this.Configuration["MobileWallet:OAuth:DefaultClientId"] ?? "mobile-wallet-test-client"; + var defaultClientSecret = this.Configuration["MobileWallet:OAuth:DefaultClientSecret"] ?? "mobile-wallet-test-secret"; + + MobileWalletClient? client = await this.Context.Clients.SingleOrDefaultAsync(c => c.ClientId == defaultClientId, cancellationToken); + if (client == null) + { + await this.Context.Clients.AddAsync(new MobileWalletClient + { + ClientId = defaultClientId, + ClientSecret = defaultClientSecret, + Name = "Default Mobile Wallet Test Client", + IsActive = true, + CreatedAt = DateTime.UtcNow + }, cancellationToken); + + await this.Context.SaveChangesAsync(cancellationToken); + } + } + + public async Task IssueTokenAsync(String clientId, String clientSecret, CancellationToken cancellationToken) + { + MobileWalletClient? client = await this.Context.Clients.SingleOrDefaultAsync(c => c.ClientId == clientId && c.IsActive, cancellationToken); + if (client == null || client.ClientSecret != clientSecret) + { + return null; + } + + Int32 tokenLifetimeMinutes = this.Configuration.GetValue("MobileWallet:OAuth:TokenLifetimeMinutes") ?? 60; + + MobileWalletAccessToken token = new() + { + TokenId = Guid.NewGuid(), + Token = Convert.ToBase64String(Guid.NewGuid().ToByteArray()).Replace("/", "_").Replace("+", "-").TrimEnd('='), + ClientId = client.ClientId, + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddMinutes(tokenLifetimeMinutes) + }; + + await this.Context.AccessTokens.AddAsync(token, cancellationToken); + await this.Context.SaveChangesAsync(cancellationToken); + return token; + } + + public async Task AuthenticateAsync(String? bearerToken, CancellationToken cancellationToken) + { + if (String.IsNullOrWhiteSpace(bearerToken)) + { + return null; + } + + MobileWalletAccessToken? accessToken = await this.Context.AccessTokens.SingleOrDefaultAsync(t => t.Token == bearerToken, cancellationToken); + if (accessToken == null || accessToken.ExpiresAt <= DateTime.UtcNow) + { + return null; + } + + return await this.Context.Clients.SingleOrDefaultAsync(c => c.ClientId == accessToken.ClientId && c.IsActive, cancellationToken); + } + + public async Task RecordAuditAsync(String resourceType, + String resourceReference, + String action, + String actor, + HttpContext? httpContext, + String? idempotencyKey, + String? requestPayload, + String? responsePayload, + CancellationToken cancellationToken) + { + await this.Context.AuditEntries.AddAsync(new MobileWalletAuditEntry + { + AuditEntryId = Guid.NewGuid(), + ResourceType = resourceType, + ResourceReference = resourceReference, + Action = action, + Actor = actor, + HttpMethod = httpContext?.Request.Method ?? "SYSTEM", + Path = httpContext?.Request.Path.Value ?? "background", + IdempotencyKey = idempotencyKey, + RequestPayload = requestPayload, + ResponsePayload = responsePayload, + CreatedAt = DateTime.UtcNow + }, cancellationToken); + + await this.Context.SaveChangesAsync(cancellationToken); + } + + public async Task FinalizeTransactionAsync(MobileWalletTransaction transaction, String targetStatus, String? statusMessage, CancellationToken cancellationToken) + { + if (transaction.Status != "pending" && transaction.Status != targetStatus) + { + return; + } + + switch (targetStatus) + { + case "completed": + await this.ApplyTransactionAsync(transaction, cancellationToken); + await this.RecordAuditAsync("transaction", + transaction.TransactionReference, + "finalize", + transaction.ClientId, + null, + transaction.IdempotencyKey, + null, + JsonConvert.SerializeObject(new + { + transactionStatus = transaction.Status, + statusMessage = transaction.StatusMessage + }), + cancellationToken); + break; + case "failed": + case "rejected": + transaction.Status = targetStatus; + transaction.StatusMessage = statusMessage ?? "Transaction rejected by the test mobile wallet."; + transaction.CompletedAt = DateTime.UtcNow; + transaction.UpdatedAt = DateTime.UtcNow; + await this.Context.SaveChangesAsync(cancellationToken); + await this.QueueWebhookDeliveriesAsync(transaction.ClientId, + $"transaction.{targetStatus}", + transaction.TransactionReference, + new + { + transactionReference = transaction.TransactionReference, + transactionStatus = transaction.Status, + statusMessage = transaction.StatusMessage + }, + transaction.CallbackUrl, + cancellationToken); + await this.RecordAuditAsync("transaction", + transaction.TransactionReference, + "finalize", + transaction.ClientId, + null, + transaction.IdempotencyKey, + null, + JsonConvert.SerializeObject(new + { + transactionStatus = transaction.Status, + statusMessage = transaction.StatusMessage + }), + cancellationToken); + break; + } + } + + public async Task ProcessPendingTransactionsAsync(CancellationToken cancellationToken) + { + List pendingTransactions = await this.Context.Transactions.Where(t => t.Status == "pending") + .OrderBy(t => t.CreatedAt) + .Take(25) + .ToListAsync(cancellationToken); + + foreach (MobileWalletTransaction transaction in pendingTransactions) + { + await this.FinalizeTransactionAsync(transaction, "completed", null, cancellationToken); + } + } + + public async Task FinalizeReversalAsync(MobileWalletReversal reversal, String targetStatus, String? statusMessage, CancellationToken cancellationToken) + { + if (reversal.Status != "pending" && reversal.Status != targetStatus) + { + return; + } + + if (targetStatus == "completed") + { + MobileWalletTransaction? originalTransaction = await this.Context.Transactions.SingleOrDefaultAsync(t => t.TransactionReference == reversal.OriginalTransactionReference, cancellationToken); + if (originalTransaction == null || originalTransaction.BalanceApplied == false || originalTransaction.Status != "completed") + { + reversal.Status = "failed"; + reversal.StatusMessage = "Original transaction cannot be reversed."; + } + else + { + MobileWalletAccount? debitAccount = await this.Context.Accounts.SingleOrDefaultAsync(a => a.AccountReference == originalTransaction.DebitAccountReference, cancellationToken); + MobileWalletAccount? creditAccount = await this.Context.Accounts.SingleOrDefaultAsync(a => a.AccountReference == originalTransaction.CreditAccountReference, cancellationToken); + + if (debitAccount == null || creditAccount == null || creditAccount.AvailableBalance < originalTransaction.Amount) + { + reversal.Status = "failed"; + reversal.StatusMessage = "Insufficient funds to process the reversal."; + } + else + { + creditAccount.AvailableBalance -= originalTransaction.Amount; + debitAccount.AvailableBalance += originalTransaction.Amount; + creditAccount.UpdatedAt = DateTime.UtcNow; + debitAccount.UpdatedAt = DateTime.UtcNow; + originalTransaction.Status = "reversed"; + originalTransaction.StatusMessage = "Transaction reversed successfully."; + originalTransaction.UpdatedAt = DateTime.UtcNow; + reversal.Status = "completed"; + reversal.StatusMessage = statusMessage ?? "Reversal completed successfully."; + } + } + } + else + { + reversal.Status = targetStatus; + reversal.StatusMessage = statusMessage ?? "Reversal rejected by the test mobile wallet."; + } + + reversal.CompletedAt = DateTime.UtcNow; + reversal.UpdatedAt = DateTime.UtcNow; + await this.Context.SaveChangesAsync(cancellationToken); + + await this.QueueWebhookDeliveriesAsync(reversal.ClientId, + $"reversal.{reversal.Status}", + reversal.ReversalReference, + new + { + reversalReference = reversal.ReversalReference, + transactionReference = reversal.OriginalTransactionReference, + reversalStatus = reversal.Status, + statusMessage = reversal.StatusMessage + }, + null, + cancellationToken); + await this.RecordAuditAsync("reversal", + reversal.ReversalReference, + "finalize", + reversal.ClientId, + null, + reversal.IdempotencyKey, + null, + JsonConvert.SerializeObject(new + { + reversalStatus = reversal.Status, + statusMessage = reversal.StatusMessage + }), + cancellationToken); + } + + public async Task ProcessPendingReversalsAsync(CancellationToken cancellationToken) + { + List pendingReversals = await this.Context.Reversals.Where(r => r.Status == "pending") + .OrderBy(r => r.CreatedAt) + .Take(25) + .ToListAsync(cancellationToken); + + foreach (MobileWalletReversal reversal in pendingReversals) + { + await this.FinalizeReversalAsync(reversal, "completed", null, cancellationToken); + } + } + + public async Task DeliverPendingWebhooksAsync(CancellationToken cancellationToken) + { + List deliveries = await this.Context.WebhookDeliveries.Where(d => d.Status == "pending" && + (d.LastAttemptAt == null || d.LastAttemptAt <= DateTime.UtcNow.AddSeconds(-30)) && + d.AttemptCount < 5) + .OrderBy(d => d.CreatedAt) + .Take(25) + .ToListAsync(cancellationToken); + + foreach (MobileWalletWebhookDelivery delivery in deliveries) + { + delivery.AttemptCount += 1; + delivery.LastAttemptAt = DateTime.UtcNow; + + try + { + HttpClient client = this.HttpClientFactory.CreateClient(); + HttpRequestMessage request = new(HttpMethod.Post, delivery.CallbackUrl) + { + Content = new StringContent(delivery.Payload, Encoding.UTF8, "application/json") + }; + request.Headers.Add("X-Webhook-Event", delivery.EventType); + request.Headers.Add("X-Webhook-Delivery-Id", delivery.DeliveryId.ToString("N")); + + HttpResponseMessage response = await client.SendAsync(request, cancellationToken); + if (response.IsSuccessStatusCode) + { + delivery.Status = "delivered"; + delivery.DeliveredAt = DateTime.UtcNow; + delivery.LastError = null; + } + else + { + delivery.LastError = $"Webhook endpoint returned HTTP {(Int32)response.StatusCode}."; + } + } + catch (Exception ex) + { + delivery.LastError = ex.Message; + } + + await this.Context.SaveChangesAsync(cancellationToken); + } + } + + private async Task ApplyTransactionAsync(MobileWalletTransaction transaction, CancellationToken cancellationToken) + { + MobileWalletAccount? debitAccount = await this.Context.Accounts.SingleOrDefaultAsync(a => a.AccountReference == transaction.DebitAccountReference, cancellationToken); + MobileWalletAccount? creditAccount = await this.Context.Accounts.SingleOrDefaultAsync(a => a.AccountReference == transaction.CreditAccountReference, cancellationToken); + + if (debitAccount == null || creditAccount == null) + { + transaction.Status = "failed"; + transaction.StatusMessage = "One or more transaction accounts were not found."; + } + else if (debitAccount.Status != "active" || creditAccount.Status != "active") + { + transaction.Status = "failed"; + transaction.StatusMessage = "Both accounts must be active before a transaction can complete."; + } + else if (debitAccount.Currency != transaction.Currency || creditAccount.Currency != transaction.Currency) + { + transaction.Status = "failed"; + transaction.StatusMessage = "Transaction currency must match both wallet account currencies."; + } + else if (debitAccount.AvailableBalance < transaction.Amount) + { + transaction.Status = "failed"; + transaction.StatusMessage = "Insufficient balance on the debit account."; + } + else if (transaction.Amount >= 50000) + { + transaction.Status = "failed"; + transaction.StatusMessage = "Amounts above the asynchronous test limit are rejected."; + } + else + { + if (transaction.BalanceApplied == false) + { + debitAccount.AvailableBalance -= transaction.Amount; + creditAccount.AvailableBalance += transaction.Amount; + debitAccount.UpdatedAt = DateTime.UtcNow; + creditAccount.UpdatedAt = DateTime.UtcNow; + transaction.BalanceApplied = true; + } + + transaction.Status = "completed"; + transaction.StatusMessage = "Transaction completed successfully."; + } + + transaction.CompletedAt = DateTime.UtcNow; + transaction.UpdatedAt = DateTime.UtcNow; + await this.Context.SaveChangesAsync(cancellationToken); + + await this.QueueWebhookDeliveriesAsync(transaction.ClientId, + $"transaction.{transaction.Status}", + transaction.TransactionReference, + new + { + transactionReference = transaction.TransactionReference, + transactionStatus = transaction.Status, + amount = transaction.Amount, + currency = transaction.Currency, + debitAccountReference = transaction.DebitAccountReference, + creditAccountReference = transaction.CreditAccountReference, + statusMessage = transaction.StatusMessage + }, + transaction.CallbackUrl, + cancellationToken); + } + + private async Task QueueWebhookDeliveriesAsync(String clientId, + String eventType, + String resourceReference, + Object payload, + String? callbackUrl, + CancellationToken cancellationToken) + { + String payloadJson = JsonConvert.SerializeObject(payload); + HashSet callbackUrls = new(StringComparer.OrdinalIgnoreCase); + + if (String.IsNullOrWhiteSpace(callbackUrl) == false) + { + callbackUrls.Add(callbackUrl); + } + + List subscriptions = await this.Context.WebhookSubscriptions.Where(s => s.ClientId == clientId && + s.IsActive && + (s.EventType == "*" || s.EventType == eventType)) + .ToListAsync(cancellationToken); + + foreach (MobileWalletWebhookSubscription subscription in subscriptions) + { + callbackUrls.Add(subscription.CallbackUrl); + } + + foreach (String targetUrl in callbackUrls) + { + await this.Context.WebhookDeliveries.AddAsync(new MobileWalletWebhookDelivery + { + DeliveryId = Guid.NewGuid(), + ClientId = clientId, + EventType = eventType, + ResourceReference = resourceReference, + CallbackUrl = targetUrl, + Payload = payloadJson, + Status = "pending", + CreatedAt = DateTime.UtcNow + }, cancellationToken); + } + + if (callbackUrls.Count > 0) + { + await this.Context.SaveChangesAsync(cancellationToken); + } + } + } +} diff --git a/TestHosts/TestHosts/Startup.cs b/TestHosts/TestHosts/Startup.cs index 5d9474c..a48e74f 100644 --- a/TestHosts/TestHosts/Startup.cs +++ b/TestHosts/TestHosts/Startup.cs @@ -7,6 +7,8 @@ using Shared.Logger; using TestHosts.Database.PataPawa; using TestHosts.Database.TestBank; +using TestHosts.Database.MobileWallet; +using TestHosts.Services.MobileWallet; namespace TestHosts { @@ -118,6 +120,12 @@ public async Task StartAsync(CancellationToken cancellationToken) // await bankContext.Database.EnsureCreatedAsync(cancellationToken); //} + MobileWalletContext mobileWalletContext = scope.ServiceProvider.GetRequiredService(); + await mobileWalletContext.Database.EnsureCreatedAsync(cancellationToken); + + MobileWalletService mobileWalletService = scope.ServiceProvider.GetRequiredService(); + await mobileWalletService.EnsureSeedDataAsync(cancellationToken); + Logger.LogWarning("Database initialization completed successfully."); } catch (Exception ex) @@ -128,4 +136,4 @@ public async Task StartAsync(CancellationToken cancellationToken) } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; -} \ No newline at end of file +} diff --git a/TestHosts/TestHosts/appsettings.json b/TestHosts/TestHosts/appsettings.json index de1d473..8105a71 100644 --- a/TestHosts/TestHosts/appsettings.json +++ b/TestHosts/TestHosts/appsettings.json @@ -7,7 +7,15 @@ }, "ConnectionStrings": { "TestBankReadModel": "server=localhost;user id=sa;password=Sc0tland;database=TestBankReadModel;Encrypt=false", - "PataPawaReadModel": "server=localhost;user id=sa;password=Sc0tland;database=PataPawaReadModel;Encrypt=false" + "PataPawaReadModel": "server=localhost;user id=sa;password=Sc0tland;database=PataPawaReadModel;Encrypt=false", + "MobileWalletReadModel": "server=localhost;user id=sa;password=Sc0tland;database=MobileWalletReadModel;Encrypt=false" + }, + "MobileWallet": { + "OAuth": { + "DefaultClientId": "mobile-wallet-test-client", + "DefaultClientSecret": "mobile-wallet-test-secret", + "TokenLifetimeMinutes": 60 + } }, "AllowedHosts": "*" } diff --git a/TestHosts/TestHosts/appsettings.preproduction.json b/TestHosts/TestHosts/appsettings.preproduction.json index 1a67e84..1a02ce6 100644 --- a/TestHosts/TestHosts/appsettings.preproduction.json +++ b/TestHosts/TestHosts/appsettings.preproduction.json @@ -1,7 +1,15 @@ { "ConnectionStrings": { "TestBankReadModel": "server=192.168.0.134,1433;user id=sa;password=sp1ttal;database=TestBankReadModel;Encrypt=false", - "PataPawaReadModel": "server=192.168.0.134,1433;user id=sa;password=sp1ttal;database=PataPawaReadModel;Encrypt=false" + "PataPawaReadModel": "server=192.168.0.134,1433;user id=sa;password=sp1ttal;database=PataPawaReadModel;Encrypt=false", + "MobileWalletReadModel": "server=192.168.0.134,1433;user id=sa;password=sp1ttal;database=MobileWalletReadModel;Encrypt=false" + }, + "MobileWallet": { + "OAuth": { + "DefaultClientId": "mobile-wallet-test-client", + "DefaultClientSecret": "mobile-wallet-test-secret", + "TokenLifetimeMinutes": 60 + } }, "AllowedHosts": "*" -} \ No newline at end of file +} diff --git a/TestHosts/TestHosts/appsettings.staging.json b/TestHosts/TestHosts/appsettings.staging.json index b0bfd90..f5f04ca 100644 --- a/TestHosts/TestHosts/appsettings.staging.json +++ b/TestHosts/TestHosts/appsettings.staging.json @@ -1,7 +1,15 @@ { "ConnectionStrings": { "TestBankReadModel": "server=192.168.1.163,1433;user id=sa;password=Sc0tland;database=TestBankReadModel;Encrypt=false", - "PataPawaReadModel": "server=192.168.1.163,1433;user id=sa;password=Sc0tland;database=PataPawaReadModel;Encrypt=false" + "PataPawaReadModel": "server=192.168.1.163,1433;user id=sa;password=Sc0tland;database=PataPawaReadModel;Encrypt=false", + "MobileWalletReadModel": "server=192.168.1.163,1433;user id=sa;password=Sc0tland;database=MobileWalletReadModel;Encrypt=false" + }, + "MobileWallet": { + "OAuth": { + "DefaultClientId": "mobile-wallet-test-client", + "DefaultClientSecret": "mobile-wallet-test-secret", + "TokenLifetimeMinutes": 60 + } }, "AllowedHosts": "*" -} \ No newline at end of file +} From d904525895ee1b363ac3d28ab68df68e50b397da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 18:33:54 +0000 Subject: [PATCH 3/5] chore: address mobile wallet review feedback Agent-Logs-Url: https://github.com/TransactionProcessing/TestHosts/sessions/df06e132-bede-4242-b612-b6451b43b5d2 Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- .../Controllers/MobileWalletController.cs | 76 +++++++++++-------- .../MobileWallet/MobileWalletContext.cs | 18 ++++- TestHosts/TestHosts/Program.cs | 2 - .../MobileWalletBackgroundService.cs | 8 +- .../MobileWallet/MobileWalletService.cs | 65 ++++++++-------- 5 files changed, 99 insertions(+), 70 deletions(-) diff --git a/TestHosts/TestHosts/Controllers/MobileWalletController.cs b/TestHosts/TestHosts/Controllers/MobileWalletController.cs index 9a491b4..f344d3f 100644 --- a/TestHosts/TestHosts/Controllers/MobileWalletController.cs +++ b/TestHosts/TestHosts/Controllers/MobileWalletController.cs @@ -62,7 +62,7 @@ await this.Service.RecordAuditAsync("oauth_token", [HttpPost("accounts")] public async Task CreateAccount([FromBody] CreateMobileWalletAccountRequest request, CancellationToken cancellationToken) { - (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + var authResult = await this.RequireClientAsync(cancellationToken); if (authResult.errorResult != null) { return authResult.errorResult; @@ -83,7 +83,7 @@ public async Task CreateAccount([FromBody] CreateMobileWalletAcco { AccountReference = request.accountReference, AccountType = request.accountType ?? "wallet", - Status = request.accountStatus ?? "active", + Status = request.accountStatus ?? MobileWalletStatus.Active, Currency = request.currency, AvailableBalance = request.initialBalance ?? 0, GivenName = request.kycInformation?.givenName ?? String.Empty, @@ -106,7 +106,7 @@ public async Task CreateAccount([FromBody] CreateMobileWalletAcco await this.Service.RecordAuditAsync("account", account.AccountReference, "create", - authResult.client.ClientId, + authResult.client!.ClientId, this.HttpContext, null, JsonConvert.SerializeObject(request), @@ -119,7 +119,7 @@ await this.Service.RecordAuditAsync("account", [HttpGet("accounts/{accountReference}")] public async Task GetAccount(String accountReference, CancellationToken cancellationToken) { - (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + var authResult = await this.RequireClientAsync(cancellationToken); if (authResult.errorResult != null) { return authResult.errorResult; @@ -135,7 +135,7 @@ public async Task GetAccount(String accountReference, Cancellatio await this.Service.RecordAuditAsync("account", account.AccountReference, "view", - authResult.client.ClientId, + authResult.client!.ClientId, this.HttpContext, null, null, @@ -148,7 +148,7 @@ await this.Service.RecordAuditAsync("account", [HttpGet("accounts/{accountReference}/balance")] public async Task GetAccountBalance(String accountReference, CancellationToken cancellationToken) { - (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + var authResult = await this.RequireClientAsync(cancellationToken); if (authResult.errorResult != null) { return authResult.errorResult; @@ -170,7 +170,7 @@ public async Task GetAccountBalance(String accountReference, Canc await this.Service.RecordAuditAsync("account_balance", account.AccountReference, "view", - authResult.client.ClientId, + authResult.client!.ClientId, this.HttpContext, null, null, @@ -183,7 +183,7 @@ await this.Service.RecordAuditAsync("account_balance", [HttpGet("accounts/{accountReference}/status")] public async Task GetAccountStatus(String accountReference, CancellationToken cancellationToken) { - (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + var authResult = await this.RequireClientAsync(cancellationToken); if (authResult.errorResult != null) { return authResult.errorResult; @@ -204,7 +204,7 @@ public async Task GetAccountStatus(String accountReference, Cance await this.Service.RecordAuditAsync("account_status", account.AccountReference, "view", - authResult.client.ClientId, + authResult.client!.ClientId, this.HttpContext, null, null, @@ -217,7 +217,7 @@ await this.Service.RecordAuditAsync("account_status", [HttpGet("accounts/{accountReference}/transactions")] public async Task GetAccountTransactions(String accountReference, CancellationToken cancellationToken) { - (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + var authResult = await this.RequireClientAsync(cancellationToken); if (authResult.errorResult != null) { return authResult.errorResult; @@ -248,7 +248,7 @@ public async Task GetAccountTransactions(String accountReference, await this.Service.RecordAuditAsync("account_transactions", accountReference, "list", - authResult.client.ClientId, + authResult.client!.ClientId, this.HttpContext, null, null, @@ -261,7 +261,7 @@ await this.Service.RecordAuditAsync("account_transactions", [HttpPost("transactions/type/{transactionType}")] public async Task CreateTransaction(String transactionType, [FromBody] CreateMobileWalletTransactionRequest request, CancellationToken cancellationToken) { - (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + var authResult = await this.RequireClientAsync(cancellationToken); if (authResult.errorResult != null) { return authResult.errorResult; @@ -280,7 +280,7 @@ public async Task CreateTransaction(String transactionType, [From } String requestPayload = JsonConvert.SerializeObject(request); - MobileWalletTransaction? existingTransaction = await this.Context.Transactions.SingleOrDefaultAsync(t => t.ClientId == authResult.client.ClientId && + MobileWalletTransaction? existingTransaction = await this.Context.Transactions.SingleOrDefaultAsync(t => t.ClientId == authResult.client!.ClientId && t.IdempotencyKey == idempotencyKey, cancellationToken); @@ -297,7 +297,7 @@ public async Task CreateTransaction(String transactionType, [From MobileWalletTransaction transaction = new() { TransactionReference = Guid.NewGuid().ToString("N"), - ClientId = authResult.client.ClientId, + ClientId = authResult.client!.ClientId, TransactionType = transactionType, Amount = request.amount, Currency = request.currency, @@ -319,7 +319,7 @@ public async Task CreateTransaction(String transactionType, [From await this.Service.RecordAuditAsync("transaction", transaction.TransactionReference, "create", - authResult.client.ClientId, + authResult.client!.ClientId, this.HttpContext, idempotencyKey, requestPayload, @@ -332,7 +332,7 @@ await this.Service.RecordAuditAsync("transaction", [HttpGet("transactions/{transactionReference}")] public async Task GetTransaction(String transactionReference, CancellationToken cancellationToken) { - (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + var authResult = await this.RequireClientAsync(cancellationToken); if (authResult.errorResult != null) { return authResult.errorResult; @@ -348,7 +348,7 @@ public async Task GetTransaction(String transactionReference, Can await this.Service.RecordAuditAsync("transaction", transaction.TransactionReference, "view", - authResult.client.ClientId, + authResult.client!.ClientId, this.HttpContext, null, null, @@ -361,7 +361,7 @@ await this.Service.RecordAuditAsync("transaction", [HttpPatch("transactions/{transactionReference}")] public async Task UpdateTransaction(String transactionReference, [FromBody] UpdateMobileWalletTransactionRequest request, CancellationToken cancellationToken) { - (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + var authResult = await this.RequireClientAsync(cancellationToken); if (authResult.errorResult != null) { return authResult.errorResult; @@ -373,17 +373,17 @@ public async Task UpdateTransaction(String transactionReference, return this.NotFound(); } - if (request.transactionStatus is not ("pending" or "completed" or "failed" or "rejected")) + if (this.IsSupportedTransactionStatus(request.transactionStatus) == false) { - return this.BadRequest(new { message = "transactionStatus must be one of pending, completed, failed or rejected." }); + return this.BadRequest(new { message = $"transactionStatus must be one of {String.Join(", ", SupportedTransactionStatuses)}." }); } - if (transaction.Status != "pending" && transaction.Status != request.transactionStatus) + if (transaction.Status != MobileWalletStatus.Pending && transaction.Status != request.transactionStatus) { return this.Conflict(new { message = "Only pending transactions can be updated." }); } - if (request.transactionStatus == "pending") + if (request.transactionStatus == MobileWalletStatus.Pending) { transaction.StatusMessage = request.statusMessage ?? transaction.StatusMessage; transaction.UpdatedAt = DateTime.UtcNow; @@ -398,7 +398,7 @@ public async Task UpdateTransaction(String transactionReference, await this.Service.RecordAuditAsync("transaction", transaction.TransactionReference, "update", - authResult.client.ClientId, + authResult.client!.ClientId, this.HttpContext, null, JsonConvert.SerializeObject(request), @@ -411,7 +411,7 @@ await this.Service.RecordAuditAsync("transaction", [HttpPost("reversals")] public async Task CreateReversal([FromBody] CreateMobileWalletReversalRequest request, CancellationToken cancellationToken) { - (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + var authResult = await this.RequireClientAsync(cancellationToken); if (authResult.errorResult != null) { return authResult.errorResult; @@ -429,7 +429,7 @@ public async Task CreateReversal([FromBody] CreateMobileWalletRev } String requestPayload = JsonConvert.SerializeObject(request); - MobileWalletReversal? existingReversal = await this.Context.Reversals.SingleOrDefaultAsync(r => r.ClientId == authResult.client.ClientId && + MobileWalletReversal? existingReversal = await this.Context.Reversals.SingleOrDefaultAsync(r => r.ClientId == authResult.client!.ClientId && r.IdempotencyKey == idempotencyKey, cancellationToken); if (existingReversal != null) @@ -450,7 +450,7 @@ public async Task CreateReversal([FromBody] CreateMobileWalletRev MobileWalletReversal reversal = new() { ReversalReference = Guid.NewGuid().ToString("N"), - ClientId = authResult.client.ClientId, + ClientId = authResult.client!.ClientId, OriginalTransactionReference = request.originalTransactionReference, Reason = request.reversalReason, IdempotencyKey = idempotencyKey, @@ -466,7 +466,7 @@ public async Task CreateReversal([FromBody] CreateMobileWalletRev await this.Service.RecordAuditAsync("reversal", reversal.ReversalReference, "create", - authResult.client.ClientId, + authResult.client!.ClientId, this.HttpContext, idempotencyKey, requestPayload, @@ -479,7 +479,7 @@ await this.Service.RecordAuditAsync("reversal", [HttpGet("reversals/{reversalReference}")] public async Task GetReversal(String reversalReference, CancellationToken cancellationToken) { - (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + var authResult = await this.RequireClientAsync(cancellationToken); if (authResult.errorResult != null) { return authResult.errorResult; @@ -495,7 +495,7 @@ public async Task GetReversal(String reversalReference, Cancellat await this.Service.RecordAuditAsync("reversal", reversal.ReversalReference, "view", - authResult.client.ClientId, + authResult.client!.ClientId, this.HttpContext, null, null, @@ -508,7 +508,7 @@ await this.Service.RecordAuditAsync("reversal", [HttpPost("webhooks/subscriptions")] public async Task CreateWebhookSubscription([FromBody] CreateMobileWalletWebhookSubscriptionRequest request, CancellationToken cancellationToken) { - (MobileWalletClient client, IActionResult errorResult) authResult = await this.RequireClientAsync(cancellationToken); + var authResult = await this.RequireClientAsync(cancellationToken); if (authResult.errorResult != null) { return authResult.errorResult; @@ -522,7 +522,7 @@ public async Task CreateWebhookSubscription([FromBody] CreateMobi MobileWalletWebhookSubscription subscription = new() { SubscriptionId = Guid.NewGuid(), - ClientId = authResult.client.ClientId, + ClientId = authResult.client!.ClientId, EventType = String.IsNullOrWhiteSpace(request.eventType) ? "*" : request.eventType, CallbackUrl = request.callbackUrl, IsActive = true, @@ -543,7 +543,7 @@ public async Task CreateWebhookSubscription([FromBody] CreateMobi await this.Service.RecordAuditAsync("webhook_subscription", subscription.SubscriptionId.ToString("N"), "create", - authResult.client.ClientId, + authResult.client!.ClientId, this.HttpContext, null, JsonConvert.SerializeObject(request), @@ -645,6 +645,18 @@ private Boolean IsValidCurrency(String? currency) currency.All(Char.IsLetter); } + private static readonly String[] SupportedTransactionStatuses = new[] { + MobileWalletStatus.Pending, + MobileWalletStatus.Completed, + MobileWalletStatus.Failed, + MobileWalletStatus.Rejected + }; + + private Boolean IsSupportedTransactionStatus(String transactionStatus) + { + return SupportedTransactionStatuses.Contains(transactionStatus, StringComparer.Ordinal); + } + private Object MapAccount(MobileWalletAccount account) { return new diff --git a/TestHosts/TestHosts/Database/MobileWallet/MobileWalletContext.cs b/TestHosts/TestHosts/Database/MobileWallet/MobileWalletContext.cs index 192499a..583dc56 100644 --- a/TestHosts/TestHosts/Database/MobileWallet/MobileWalletContext.cs +++ b/TestHosts/TestHosts/Database/MobileWallet/MobileWalletContext.cs @@ -4,6 +4,16 @@ namespace TestHosts.Database.MobileWallet using Shared.General; using System; + internal static class MobileWalletStatus + { + internal const String Pending = "pending"; + internal const String Completed = "completed"; + internal const String Failed = "failed"; + internal const String Rejected = "rejected"; + internal const String Reversed = "reversed"; + internal const String Active = "active"; + } + public class MobileWalletContext : DbContext { private readonly String ConnectionString; @@ -83,7 +93,7 @@ public sealed class MobileWalletAccount { public String AccountReference { get; set; } = String.Empty; public String AccountType { get; set; } = "wallet"; - public String Status { get; set; } = "active"; + public String Status { get; set; } = MobileWalletStatus.Active; public String Currency { get; set; } = "USD"; public Decimal AvailableBalance { get; set; } public String GivenName { get; set; } = String.Empty; @@ -104,7 +114,7 @@ public sealed class MobileWalletTransaction public String TransactionReference { get; set; } = String.Empty; public String ClientId { get; set; } = String.Empty; public String TransactionType { get; set; } = String.Empty; - public String Status { get; set; } = "pending"; + public String Status { get; set; } = MobileWalletStatus.Pending; public String StatusMessage { get; set; } = "Transaction accepted for asynchronous processing."; public Decimal Amount { get; set; } public String Currency { get; set; } = "USD"; @@ -127,7 +137,7 @@ public sealed class MobileWalletReversal public String ClientId { get; set; } = String.Empty; public String OriginalTransactionReference { get; set; } = String.Empty; public String Reason { get; set; } = String.Empty; - public String Status { get; set; } = "pending"; + public String Status { get; set; } = MobileWalletStatus.Pending; public String StatusMessage { get; set; } = "Reversal accepted for asynchronous processing."; public String IdempotencyKey { get; set; } = String.Empty; public String RequestPayload { get; set; } = String.Empty; @@ -154,7 +164,7 @@ public sealed class MobileWalletWebhookDelivery public String ResourceReference { get; set; } = String.Empty; public String CallbackUrl { get; set; } = String.Empty; public String Payload { get; set; } = String.Empty; - public String Status { get; set; } = "pending"; + public String Status { get; set; } = MobileWalletStatus.Pending; public Int32 AttemptCount { get; set; } public DateTime CreatedAt { get; set; } public DateTime? LastAttemptAt { get; set; } diff --git a/TestHosts/TestHosts/Program.cs b/TestHosts/TestHosts/Program.cs index e43847d..971d27d 100644 --- a/TestHosts/TestHosts/Program.cs +++ b/TestHosts/TestHosts/Program.cs @@ -138,8 +138,6 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); - builder.Services.AddScoped(x => new TenantContext()); - builder.Services.AddSingleton(); builder.Services.AddMvc(); builder.Services.AddServiceModelServices().AddServiceModelMetadata(); diff --git a/TestHosts/TestHosts/Services/MobileWallet/MobileWalletBackgroundService.cs b/TestHosts/TestHosts/Services/MobileWallet/MobileWalletBackgroundService.cs index b8850c7..f0bc80f 100644 --- a/TestHosts/TestHosts/Services/MobileWallet/MobileWalletBackgroundService.cs +++ b/TestHosts/TestHosts/Services/MobileWallet/MobileWalletBackgroundService.cs @@ -2,6 +2,7 @@ namespace TestHosts.Services.MobileWallet { using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; + using Microsoft.Extensions.Logging; using System; using System.Threading; using System.Threading.Tasks; @@ -9,10 +10,12 @@ namespace TestHosts.Services.MobileWallet public sealed class MobileWalletBackgroundService : BackgroundService { private readonly IServiceScopeFactory ScopeFactory; + private readonly ILogger Logger; - public MobileWalletBackgroundService(IServiceScopeFactory scopeFactory) + public MobileWalletBackgroundService(IServiceScopeFactory scopeFactory, ILogger logger) { this.ScopeFactory = scopeFactory; + this.Logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -27,8 +30,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) await service.ProcessPendingReversalsAsync(stoppingToken); await service.DeliverPendingWebhooksAsync(stoppingToken); } - catch + catch (Exception ex) { + this.Logger.LogError(ex, "Mobile wallet background processing failed."); } await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); diff --git a/TestHosts/TestHosts/Services/MobileWallet/MobileWalletService.cs b/TestHosts/TestHosts/Services/MobileWallet/MobileWalletService.cs index 66e53d5..cb4083f 100644 --- a/TestHosts/TestHosts/Services/MobileWallet/MobileWalletService.cs +++ b/TestHosts/TestHosts/Services/MobileWallet/MobileWalletService.cs @@ -15,6 +15,11 @@ namespace TestHosts.Services.MobileWallet public sealed class MobileWalletService { + private const Int32 PendingBatchSize = 25; + private const Int32 MaxWebhookAttempts = 5; + private const Int32 WebhookRetryDelaySeconds = 30; + private const Decimal TestAsyncAmountLimit = 50000m; + private readonly MobileWalletContext Context; private readonly IConfiguration Configuration; private readonly IHttpClientFactory HttpClientFactory; @@ -117,14 +122,14 @@ await this.Context.AuditEntries.AddAsync(new MobileWalletAuditEntry public async Task FinalizeTransactionAsync(MobileWalletTransaction transaction, String targetStatus, String? statusMessage, CancellationToken cancellationToken) { - if (transaction.Status != "pending" && transaction.Status != targetStatus) + if (transaction.Status != MobileWalletStatus.Pending && transaction.Status != targetStatus) { return; } switch (targetStatus) { - case "completed": + case MobileWalletStatus.Completed: await this.ApplyTransactionAsync(transaction, cancellationToken); await this.RecordAuditAsync("transaction", transaction.TransactionReference, @@ -140,8 +145,8 @@ await this.RecordAuditAsync("transaction", }), cancellationToken); break; - case "failed": - case "rejected": + case MobileWalletStatus.Failed: + case MobileWalletStatus.Rejected: transaction.Status = targetStatus; transaction.StatusMessage = statusMessage ?? "Transaction rejected by the test mobile wallet."; transaction.CompletedAt = DateTime.UtcNow; @@ -177,30 +182,30 @@ await this.RecordAuditAsync("transaction", public async Task ProcessPendingTransactionsAsync(CancellationToken cancellationToken) { - List pendingTransactions = await this.Context.Transactions.Where(t => t.Status == "pending") + List pendingTransactions = await this.Context.Transactions.Where(t => t.Status == MobileWalletStatus.Pending) .OrderBy(t => t.CreatedAt) - .Take(25) + .Take(PendingBatchSize) .ToListAsync(cancellationToken); foreach (MobileWalletTransaction transaction in pendingTransactions) { - await this.FinalizeTransactionAsync(transaction, "completed", null, cancellationToken); + await this.FinalizeTransactionAsync(transaction, MobileWalletStatus.Completed, null, cancellationToken); } } public async Task FinalizeReversalAsync(MobileWalletReversal reversal, String targetStatus, String? statusMessage, CancellationToken cancellationToken) { - if (reversal.Status != "pending" && reversal.Status != targetStatus) + if (reversal.Status != MobileWalletStatus.Pending && reversal.Status != targetStatus) { return; } - if (targetStatus == "completed") + if (targetStatus == MobileWalletStatus.Completed) { MobileWalletTransaction? originalTransaction = await this.Context.Transactions.SingleOrDefaultAsync(t => t.TransactionReference == reversal.OriginalTransactionReference, cancellationToken); - if (originalTransaction == null || originalTransaction.BalanceApplied == false || originalTransaction.Status != "completed") + if (originalTransaction == null || originalTransaction.BalanceApplied == false || originalTransaction.Status != MobileWalletStatus.Completed) { - reversal.Status = "failed"; + reversal.Status = MobileWalletStatus.Failed; reversal.StatusMessage = "Original transaction cannot be reversed."; } else @@ -210,7 +215,7 @@ public async Task FinalizeReversalAsync(MobileWalletReversal reversal, String ta if (debitAccount == null || creditAccount == null || creditAccount.AvailableBalance < originalTransaction.Amount) { - reversal.Status = "failed"; + reversal.Status = MobileWalletStatus.Failed; reversal.StatusMessage = "Insufficient funds to process the reversal."; } else @@ -219,10 +224,10 @@ public async Task FinalizeReversalAsync(MobileWalletReversal reversal, String ta debitAccount.AvailableBalance += originalTransaction.Amount; creditAccount.UpdatedAt = DateTime.UtcNow; debitAccount.UpdatedAt = DateTime.UtcNow; - originalTransaction.Status = "reversed"; + originalTransaction.Status = MobileWalletStatus.Reversed; originalTransaction.StatusMessage = "Transaction reversed successfully."; originalTransaction.UpdatedAt = DateTime.UtcNow; - reversal.Status = "completed"; + reversal.Status = MobileWalletStatus.Completed; reversal.StatusMessage = statusMessage ?? "Reversal completed successfully."; } } @@ -266,24 +271,24 @@ await this.RecordAuditAsync("reversal", public async Task ProcessPendingReversalsAsync(CancellationToken cancellationToken) { - List pendingReversals = await this.Context.Reversals.Where(r => r.Status == "pending") + List pendingReversals = await this.Context.Reversals.Where(r => r.Status == MobileWalletStatus.Pending) .OrderBy(r => r.CreatedAt) - .Take(25) + .Take(PendingBatchSize) .ToListAsync(cancellationToken); foreach (MobileWalletReversal reversal in pendingReversals) { - await this.FinalizeReversalAsync(reversal, "completed", null, cancellationToken); + await this.FinalizeReversalAsync(reversal, MobileWalletStatus.Completed, null, cancellationToken); } } public async Task DeliverPendingWebhooksAsync(CancellationToken cancellationToken) { - List deliveries = await this.Context.WebhookDeliveries.Where(d => d.Status == "pending" && - (d.LastAttemptAt == null || d.LastAttemptAt <= DateTime.UtcNow.AddSeconds(-30)) && - d.AttemptCount < 5) + List deliveries = await this.Context.WebhookDeliveries.Where(d => d.Status == MobileWalletStatus.Pending && + (d.LastAttemptAt == null || d.LastAttemptAt <= DateTime.UtcNow.AddSeconds(-WebhookRetryDelaySeconds)) && + d.AttemptCount < MaxWebhookAttempts) .OrderBy(d => d.CreatedAt) - .Take(25) + .Take(PendingBatchSize) .ToListAsync(cancellationToken); foreach (MobileWalletWebhookDelivery delivery in deliveries) @@ -329,27 +334,27 @@ private async Task ApplyTransactionAsync(MobileWalletTransaction transaction, Ca if (debitAccount == null || creditAccount == null) { - transaction.Status = "failed"; + transaction.Status = MobileWalletStatus.Failed; transaction.StatusMessage = "One or more transaction accounts were not found."; } - else if (debitAccount.Status != "active" || creditAccount.Status != "active") + else if (debitAccount.Status != MobileWalletStatus.Active || creditAccount.Status != MobileWalletStatus.Active) { - transaction.Status = "failed"; + transaction.Status = MobileWalletStatus.Failed; transaction.StatusMessage = "Both accounts must be active before a transaction can complete."; } else if (debitAccount.Currency != transaction.Currency || creditAccount.Currency != transaction.Currency) { - transaction.Status = "failed"; + transaction.Status = MobileWalletStatus.Failed; transaction.StatusMessage = "Transaction currency must match both wallet account currencies."; } else if (debitAccount.AvailableBalance < transaction.Amount) { - transaction.Status = "failed"; + transaction.Status = MobileWalletStatus.Failed; transaction.StatusMessage = "Insufficient balance on the debit account."; } - else if (transaction.Amount >= 50000) + else if (transaction.Amount >= TestAsyncAmountLimit) { - transaction.Status = "failed"; + transaction.Status = MobileWalletStatus.Failed; transaction.StatusMessage = "Amounts above the asynchronous test limit are rejected."; } else @@ -363,7 +368,7 @@ private async Task ApplyTransactionAsync(MobileWalletTransaction transaction, Ca transaction.BalanceApplied = true; } - transaction.Status = "completed"; + transaction.Status = MobileWalletStatus.Completed; transaction.StatusMessage = "Transaction completed successfully."; } @@ -423,7 +428,7 @@ await this.Context.WebhookDeliveries.AddAsync(new MobileWalletWebhookDelivery ResourceReference = resourceReference, CallbackUrl = targetUrl, Payload = payloadJson, - Status = "pending", + Status = MobileWalletStatus.Pending, CreatedAt = DateTime.UtcNow }, cancellationToken); } From 3f5ba3c15d4069768ba428e0cfca8413042cee94 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 17:48:33 +0000 Subject: [PATCH 4/5] feat: add mobile wallet ef migrations Agent-Logs-Url: https://github.com/TransactionProcessing/TestHosts/sessions/22ac0236-f222-47b7-b0e2-506f034686a5 Co-authored-by: StuartFerguson <16325469+StuartFerguson@users.noreply.github.com> --- ...15180000_InitialMobileWalletDb.Designer.cs | 354 ++++++++++++++++++ .../20260515180000_InitialMobileWalletDb.cs | 231 ++++++++++++ .../MobileWalletContextModelSnapshot.cs | 352 +++++++++++++++++ TestHosts/TestHosts/Startup.cs | 7 +- 4 files changed, 943 insertions(+), 1 deletion(-) create mode 100644 TestHosts/TestHosts/Database/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.Designer.cs create mode 100644 TestHosts/TestHosts/Database/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.cs create mode 100644 TestHosts/TestHosts/Database/MobileWallet/Migrations/MobileWalletContextModelSnapshot.cs diff --git a/TestHosts/TestHosts/Database/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.Designer.cs b/TestHosts/TestHosts/Database/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.Designer.cs new file mode 100644 index 0000000..b87fecc --- /dev/null +++ b/TestHosts/TestHosts/Database/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.Designer.cs @@ -0,0 +1,354 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TestHosts.Database.MobileWallet; + +#nullable disable + +namespace TestHosts.Database.MobileWallet.Migrations +{ + [DbContext(typeof(MobileWalletContext))] + [Migration("20260515180000_InitialMobileWalletDb")] + partial class InitialMobileWalletDb + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("TestHosts.Database.MobileWallet.MobileWalletAccessToken", b => + { + b.Property("TokenId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClientId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .HasColumnType("nvarchar(450)"); + + b.HasKey("TokenId"); + + b.HasIndex("Token") + .IsUnique() + .HasFilter("[Token] IS NOT NULL"); + + b.ToTable("AccessTokens"); + }); + + modelBuilder.Entity("TestHosts.Database.MobileWallet.MobileWalletAccount", b => + { + b.Property("AccountReference") + .HasColumnType("nvarchar(450)"); + + b.Property("AccountType") + .HasColumnType("nvarchar(max)"); + + b.Property("AvailableBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Currency") + .HasColumnType("nvarchar(max)"); + + b.Property("DateOfBirth") + .HasColumnType("nvarchar(max)"); + + b.Property("EmailAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)"); + + b.Property("GivenName") + .HasColumnType("nvarchar(max)"); + + b.Property("IdentityNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("IdentityType") + .HasColumnType("nvarchar(max)"); + + b.Property("KycStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("Msisdn") + .HasColumnType("nvarchar(max)"); + + b.Property("Nationality") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("AccountReference"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("TestHosts.Database.MobileWallet.MobileWalletAuditEntry", b => + { + b.Property("AuditEntryId") + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("HttpMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("IdempotencyKey") + .HasColumnType("nvarchar(max)"); + + b.Property("Path") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestPayload") + .HasColumnType("nvarchar(max)"); + + b.Property("ResourceReference") + .HasColumnType("nvarchar(max)"); + + b.Property("ResourceType") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponsePayload") + .HasColumnType("nvarchar(max)"); + + b.HasKey("AuditEntryId"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("TestHosts.Database.MobileWallet.MobileWalletClient", b => + { + b.Property("ClientId") + .HasColumnType("nvarchar(450)"); + + b.Property("CallbackUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.HasKey("ClientId"); + + b.ToTable("Clients"); + }); + + modelBuilder.Entity("TestHosts.Database.MobileWallet.MobileWalletReversal", b => + { + b.Property("ReversalReference") + .HasColumnType("nvarchar(450)"); + + b.Property("ClientId") + .HasColumnType("nvarchar(max)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IdempotencyKey") + .HasColumnType("nvarchar(450)"); + + b.Property("OriginalTransactionReference") + .HasColumnType("nvarchar(max)"); + + b.Property("Reason") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestPayload") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("ReversalReference"); + + b.HasIndex("ClientId", "IdempotencyKey") + .IsUnique() + .HasFilter("[ClientId] IS NOT NULL AND [IdempotencyKey] IS NOT NULL"); + + b.ToTable("Reversals"); + }); + + modelBuilder.Entity("TestHosts.Database.MobileWallet.MobileWalletTransaction", b => + { + b.Property("TransactionReference") + .HasColumnType("nvarchar(450)"); + + b.Property("BalanceApplied") + .HasColumnType("bit"); + + b.Property("CallbackUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("ClientId") + .HasColumnType("nvarchar(max)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreditAccountReference") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Currency") + .HasColumnType("nvarchar(max)"); + + b.Property("DebitAccountReference") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalReference") + .HasColumnType("nvarchar(450)"); + + b.Property("IdempotencyKey") + .HasColumnType("nvarchar(450)"); + + b.Property("MetadataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("RequestPayload") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("TransactionReference"); + + b.HasIndex("ClientId", "IdempotencyKey") + .IsUnique() + .HasFilter("[ClientId] IS NOT NULL AND [IdempotencyKey] IS NOT NULL"); + + b.HasIndex("ExternalReference"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("TestHosts.Database.MobileWallet.MobileWalletWebhookDelivery", b => + { + b.Property("DeliveryId") + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptCount") + .HasColumnType("int"); + + b.Property("CallbackUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("ClientId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeliveredAt") + .HasColumnType("datetime2"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("LastAttemptAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Payload") + .HasColumnType("nvarchar(max)"); + + b.Property("ResourceReference") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.HasKey("DeliveryId"); + + b.ToTable("WebhookDeliveries"); + }); + + modelBuilder.Entity("TestHosts.Database.MobileWallet.MobileWalletWebhookSubscription", b => + { + b.Property("SubscriptionId") + .HasColumnType("uniqueidentifier"); + + b.Property("CallbackUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("ClientId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.HasKey("SubscriptionId"); + + b.ToTable("WebhookSubscriptions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TestHosts/TestHosts/Database/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.cs b/TestHosts/TestHosts/Database/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.cs new file mode 100644 index 0000000..1687e3f --- /dev/null +++ b/TestHosts/TestHosts/Database/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.cs @@ -0,0 +1,231 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TestHosts.Database.MobileWallet.Migrations +{ + public partial class InitialMobileWalletDb : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AccessTokens", + columns: table => new + { + TokenId = table.Column(type: "uniqueidentifier", nullable: false), + Token = table.Column(type: "nvarchar(450)", nullable: true), + ClientId = table.Column(type: "nvarchar(max)", nullable: true), + ExpiresAt = table.Column(type: "datetime2", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AccessTokens", x => x.TokenId); + }); + + migrationBuilder.CreateTable( + name: "Accounts", + columns: table => new + { + AccountReference = table.Column(type: "nvarchar(450)", nullable: false), + AccountType = table.Column(type: "nvarchar(max)", nullable: true), + Status = table.Column(type: "nvarchar(max)", nullable: true), + Currency = table.Column(type: "nvarchar(max)", nullable: true), + AvailableBalance = table.Column(type: "decimal(18,2)", nullable: false), + GivenName = table.Column(type: "nvarchar(max)", nullable: true), + FamilyName = table.Column(type: "nvarchar(max)", nullable: true), + Msisdn = table.Column(type: "nvarchar(max)", nullable: true), + EmailAddress = table.Column(type: "nvarchar(max)", nullable: true), + KycStatus = table.Column(type: "nvarchar(max)", nullable: true), + IdentityType = table.Column(type: "nvarchar(max)", nullable: true), + IdentityNumber = table.Column(type: "nvarchar(max)", nullable: true), + DateOfBirth = table.Column(type: "nvarchar(max)", nullable: true), + Nationality = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Accounts", x => x.AccountReference); + }); + + migrationBuilder.CreateTable( + name: "AuditEntries", + columns: table => new + { + AuditEntryId = table.Column(type: "uniqueidentifier", nullable: false), + ResourceType = table.Column(type: "nvarchar(max)", nullable: true), + ResourceReference = table.Column(type: "nvarchar(max)", nullable: true), + Action = table.Column(type: "nvarchar(max)", nullable: true), + Actor = table.Column(type: "nvarchar(max)", nullable: true), + HttpMethod = table.Column(type: "nvarchar(max)", nullable: true), + Path = table.Column(type: "nvarchar(max)", nullable: true), + IdempotencyKey = table.Column(type: "nvarchar(max)", nullable: true), + RequestPayload = table.Column(type: "nvarchar(max)", nullable: true), + ResponsePayload = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditEntries", x => x.AuditEntryId); + }); + + migrationBuilder.CreateTable( + name: "Clients", + columns: table => new + { + ClientId = table.Column(type: "nvarchar(450)", nullable: false), + ClientSecret = table.Column(type: "nvarchar(max)", nullable: true), + Name = table.Column(type: "nvarchar(max)", nullable: true), + CallbackUrl = table.Column(type: "nvarchar(max)", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Clients", x => x.ClientId); + }); + + migrationBuilder.CreateTable( + name: "Reversals", + columns: table => new + { + ReversalReference = table.Column(type: "nvarchar(450)", nullable: false), + ClientId = table.Column(type: "nvarchar(max)", nullable: true), + OriginalTransactionReference = table.Column(type: "nvarchar(max)", nullable: true), + Reason = table.Column(type: "nvarchar(max)", nullable: true), + Status = table.Column(type: "nvarchar(max)", nullable: true), + StatusMessage = table.Column(type: "nvarchar(max)", nullable: true), + IdempotencyKey = table.Column(type: "nvarchar(450)", nullable: true), + RequestPayload = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: false), + CompletedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Reversals", x => x.ReversalReference); + }); + + migrationBuilder.CreateTable( + name: "Transactions", + columns: table => new + { + TransactionReference = table.Column(type: "nvarchar(450)", nullable: false), + ClientId = table.Column(type: "nvarchar(max)", nullable: true), + TransactionType = table.Column(type: "nvarchar(max)", nullable: true), + Status = table.Column(type: "nvarchar(max)", nullable: true), + StatusMessage = table.Column(type: "nvarchar(max)", nullable: true), + Amount = table.Column(type: "decimal(18,2)", nullable: false), + Currency = table.Column(type: "nvarchar(max)", nullable: true), + DebitAccountReference = table.Column(type: "nvarchar(max)", nullable: true), + CreditAccountReference = table.Column(type: "nvarchar(max)", nullable: true), + ExternalReference = table.Column(type: "nvarchar(450)", nullable: true), + IdempotencyKey = table.Column(type: "nvarchar(450)", nullable: true), + RequestPayload = table.Column(type: "nvarchar(max)", nullable: true), + CallbackUrl = table.Column(type: "nvarchar(max)", nullable: true), + MetadataJson = table.Column(type: "nvarchar(max)", nullable: true), + BalanceApplied = table.Column(type: "bit", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(type: "datetime2", nullable: false), + CompletedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Transactions", x => x.TransactionReference); + }); + + migrationBuilder.CreateTable( + name: "WebhookDeliveries", + columns: table => new + { + DeliveryId = table.Column(type: "uniqueidentifier", nullable: false), + ClientId = table.Column(type: "nvarchar(max)", nullable: true), + EventType = table.Column(type: "nvarchar(max)", nullable: true), + ResourceReference = table.Column(type: "nvarchar(max)", nullable: true), + CallbackUrl = table.Column(type: "nvarchar(max)", nullable: true), + Payload = table.Column(type: "nvarchar(max)", nullable: true), + Status = table.Column(type: "nvarchar(max)", nullable: true), + AttemptCount = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + LastAttemptAt = table.Column(type: "datetime2", nullable: true), + DeliveredAt = table.Column(type: "datetime2", nullable: true), + LastError = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_WebhookDeliveries", x => x.DeliveryId); + }); + + migrationBuilder.CreateTable( + name: "WebhookSubscriptions", + columns: table => new + { + SubscriptionId = table.Column(type: "uniqueidentifier", nullable: false), + ClientId = table.Column(type: "nvarchar(max)", nullable: true), + EventType = table.Column(type: "nvarchar(max)", nullable: true), + CallbackUrl = table.Column(type: "nvarchar(max)", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WebhookSubscriptions", x => x.SubscriptionId); + }); + + migrationBuilder.CreateIndex( + name: "IX_AccessTokens_Token", + table: "AccessTokens", + column: "Token", + unique: true, + filter: "[Token] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Reversals_ClientId_IdempotencyKey", + table: "Reversals", + columns: new[] { "ClientId", "IdempotencyKey" }, + unique: true, + filter: "[ClientId] IS NOT NULL AND [IdempotencyKey] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_ClientId_IdempotencyKey", + table: "Transactions", + columns: new[] { "ClientId", "IdempotencyKey" }, + unique: true, + filter: "[ClientId] IS NOT NULL AND [IdempotencyKey] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Transactions_ExternalReference", + table: "Transactions", + column: "ExternalReference"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AccessTokens"); + + migrationBuilder.DropTable( + name: "Accounts"); + + migrationBuilder.DropTable( + name: "AuditEntries"); + + migrationBuilder.DropTable( + name: "Clients"); + + migrationBuilder.DropTable( + name: "Reversals"); + + migrationBuilder.DropTable( + name: "Transactions"); + + migrationBuilder.DropTable( + name: "WebhookDeliveries"); + + migrationBuilder.DropTable( + name: "WebhookSubscriptions"); + } + } +} diff --git a/TestHosts/TestHosts/Database/MobileWallet/Migrations/MobileWalletContextModelSnapshot.cs b/TestHosts/TestHosts/Database/MobileWallet/Migrations/MobileWalletContextModelSnapshot.cs new file mode 100644 index 0000000..f7b55aa --- /dev/null +++ b/TestHosts/TestHosts/Database/MobileWallet/Migrations/MobileWalletContextModelSnapshot.cs @@ -0,0 +1,352 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TestHosts.Database.MobileWallet; + +#nullable disable + +namespace TestHosts.Database.MobileWallet.Migrations +{ + [DbContext(typeof(MobileWalletContext))] + partial class MobileWalletContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("TestHosts.Database.MobileWallet.MobileWalletAccessToken", b => + { + b.Property("TokenId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClientId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .HasColumnType("nvarchar(450)"); + + b.HasKey("TokenId"); + + b.HasIndex("Token") + .IsUnique() + .HasFilter("[Token] IS NOT NULL"); + + b.ToTable("AccessTokens"); + }); + + modelBuilder.Entity("TestHosts.Database.MobileWallet.MobileWalletAccount", b => + { + b.Property("AccountReference") + .HasColumnType("nvarchar(450)"); + + b.Property("AccountType") + .HasColumnType("nvarchar(max)"); + + b.Property("AvailableBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Currency") + .HasColumnType("nvarchar(max)"); + + b.Property("DateOfBirth") + .HasColumnType("nvarchar(max)"); + + b.Property("EmailAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("FamilyName") + .HasColumnType("nvarchar(max)"); + + b.Property("GivenName") + .HasColumnType("nvarchar(max)"); + + b.Property("IdentityNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("IdentityType") + .HasColumnType("nvarchar(max)"); + + b.Property("KycStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("Msisdn") + .HasColumnType("nvarchar(max)"); + + b.Property("Nationality") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("AccountReference"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("TestHosts.Database.MobileWallet.MobileWalletAuditEntry", b => + { + b.Property("AuditEntryId") + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("HttpMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("IdempotencyKey") + .HasColumnType("nvarchar(max)"); + + b.Property("Path") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestPayload") + .HasColumnType("nvarchar(max)"); + + b.Property("ResourceReference") + .HasColumnType("nvarchar(max)"); + + b.Property("ResourceType") + .HasColumnType("nvarchar(max)"); + + b.Property("ResponsePayload") + .HasColumnType("nvarchar(max)"); + + b.HasKey("AuditEntryId"); + + b.ToTable("AuditEntries"); + }); + + modelBuilder.Entity("TestHosts.Database.MobileWallet.MobileWalletClient", b => + { + b.Property("ClientId") + .HasColumnType("nvarchar(450)"); + + b.Property("CallbackUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("ClientSecret") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.HasKey("ClientId"); + + b.ToTable("Clients"); + }); + + modelBuilder.Entity("TestHosts.Database.MobileWallet.MobileWalletReversal", b => + { + b.Property("ReversalReference") + .HasColumnType("nvarchar(450)"); + + b.Property("ClientId") + .HasColumnType("nvarchar(max)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IdempotencyKey") + .HasColumnType("nvarchar(450)"); + + b.Property("OriginalTransactionReference") + .HasColumnType("nvarchar(max)"); + + b.Property("Reason") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestPayload") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("ReversalReference"); + + b.HasIndex("ClientId", "IdempotencyKey") + .IsUnique() + .HasFilter("[ClientId] IS NOT NULL AND [IdempotencyKey] IS NOT NULL"); + + b.ToTable("Reversals"); + }); + + modelBuilder.Entity("TestHosts.Database.MobileWallet.MobileWalletTransaction", b => + { + b.Property("TransactionReference") + .HasColumnType("nvarchar(450)"); + + b.Property("BalanceApplied") + .HasColumnType("bit"); + + b.Property("CallbackUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("ClientId") + .HasColumnType("nvarchar(max)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreditAccountReference") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Currency") + .HasColumnType("nvarchar(max)"); + + b.Property("DebitAccountReference") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalReference") + .HasColumnType("nvarchar(450)"); + + b.Property("IdempotencyKey") + .HasColumnType("nvarchar(450)"); + + b.Property("MetadataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("RequestPayload") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("StatusMessage") + .HasColumnType("nvarchar(max)"); + + b.Property("TransactionType") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("TransactionReference"); + + b.HasIndex("ClientId", "IdempotencyKey") + .IsUnique() + .HasFilter("[ClientId] IS NOT NULL AND [IdempotencyKey] IS NOT NULL"); + + b.HasIndex("ExternalReference"); + + b.ToTable("Transactions"); + }); + + modelBuilder.Entity("TestHosts.Database.MobileWallet.MobileWalletWebhookDelivery", b => + { + b.Property("DeliveryId") + .HasColumnType("uniqueidentifier"); + + b.Property("AttemptCount") + .HasColumnType("int"); + + b.Property("CallbackUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("ClientId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeliveredAt") + .HasColumnType("datetime2"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("LastAttemptAt") + .HasColumnType("datetime2"); + + b.Property("LastError") + .HasColumnType("nvarchar(max)"); + + b.Property("Payload") + .HasColumnType("nvarchar(max)"); + + b.Property("ResourceReference") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.HasKey("DeliveryId"); + + b.ToTable("WebhookDeliveries"); + }); + + modelBuilder.Entity("TestHosts.Database.MobileWallet.MobileWalletWebhookSubscription", b => + { + b.Property("SubscriptionId") + .HasColumnType("uniqueidentifier"); + + b.Property("CallbackUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("ClientId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.HasKey("SubscriptionId"); + + b.ToTable("WebhookSubscriptions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TestHosts/TestHosts/Startup.cs b/TestHosts/TestHosts/Startup.cs index a48e74f..59fc023 100644 --- a/TestHosts/TestHosts/Startup.cs +++ b/TestHosts/TestHosts/Startup.cs @@ -121,7 +121,12 @@ public async Task StartAsync(CancellationToken cancellationToken) //} MobileWalletContext mobileWalletContext = scope.ServiceProvider.GetRequiredService(); - await mobileWalletContext.Database.EnsureCreatedAsync(cancellationToken); + if (mobileWalletContext.Database.IsRelational()) { + await mobileWalletContext.Database.MigrateAsync(cancellationToken); + } + else { + await mobileWalletContext.Database.EnsureCreatedAsync(cancellationToken); + } MobileWalletService mobileWalletService = scope.ServiceProvider.GetRequiredService(); await mobileWalletService.EnsureSeedDataAsync(cancellationToken); From 95835b9a2f75ca8d157994d85f07a19b839d3884 Mon Sep 17 00:00:00 2001 From: Stuart Ferguson Date: Sat, 16 May 2026 12:07:31 +0100 Subject: [PATCH 5/5] move the migrations files --- .../Migrations/20260515180000_InitialMobileWalletDb.Designer.cs | 0 .../Migrations/20260515180000_InitialMobileWalletDb.cs | 0 .../MobileWallet/Migrations/MobileWalletContextModelSnapshot.cs | 0 .../Migrations/20211014160720_InitialTestBankDb.Designer.cs | 0 .../TestBank/Migrations/20211014160720_InitialTestBankDb.cs | 0 .../TestBank/Migrations/TestBankContextModelSnapshot.cs | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename TestHosts/TestHosts/{Database => Migrations}/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.Designer.cs (100%) rename TestHosts/TestHosts/{Database => Migrations}/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.cs (100%) rename TestHosts/TestHosts/{Database => Migrations}/MobileWallet/Migrations/MobileWalletContextModelSnapshot.cs (100%) rename TestHosts/TestHosts/{Database => Migrations}/TestBank/Migrations/20211014160720_InitialTestBankDb.Designer.cs (100%) rename TestHosts/TestHosts/{Database => Migrations}/TestBank/Migrations/20211014160720_InitialTestBankDb.cs (100%) rename TestHosts/TestHosts/{Database => Migrations}/TestBank/Migrations/TestBankContextModelSnapshot.cs (100%) diff --git a/TestHosts/TestHosts/Database/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.Designer.cs b/TestHosts/TestHosts/Migrations/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.Designer.cs similarity index 100% rename from TestHosts/TestHosts/Database/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.Designer.cs rename to TestHosts/TestHosts/Migrations/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.Designer.cs diff --git a/TestHosts/TestHosts/Database/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.cs b/TestHosts/TestHosts/Migrations/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.cs similarity index 100% rename from TestHosts/TestHosts/Database/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.cs rename to TestHosts/TestHosts/Migrations/MobileWallet/Migrations/20260515180000_InitialMobileWalletDb.cs diff --git a/TestHosts/TestHosts/Database/MobileWallet/Migrations/MobileWalletContextModelSnapshot.cs b/TestHosts/TestHosts/Migrations/MobileWallet/Migrations/MobileWalletContextModelSnapshot.cs similarity index 100% rename from TestHosts/TestHosts/Database/MobileWallet/Migrations/MobileWalletContextModelSnapshot.cs rename to TestHosts/TestHosts/Migrations/MobileWallet/Migrations/MobileWalletContextModelSnapshot.cs diff --git a/TestHosts/TestHosts/Database/TestBank/Migrations/20211014160720_InitialTestBankDb.Designer.cs b/TestHosts/TestHosts/Migrations/TestBank/Migrations/20211014160720_InitialTestBankDb.Designer.cs similarity index 100% rename from TestHosts/TestHosts/Database/TestBank/Migrations/20211014160720_InitialTestBankDb.Designer.cs rename to TestHosts/TestHosts/Migrations/TestBank/Migrations/20211014160720_InitialTestBankDb.Designer.cs diff --git a/TestHosts/TestHosts/Database/TestBank/Migrations/20211014160720_InitialTestBankDb.cs b/TestHosts/TestHosts/Migrations/TestBank/Migrations/20211014160720_InitialTestBankDb.cs similarity index 100% rename from TestHosts/TestHosts/Database/TestBank/Migrations/20211014160720_InitialTestBankDb.cs rename to TestHosts/TestHosts/Migrations/TestBank/Migrations/20211014160720_InitialTestBankDb.cs diff --git a/TestHosts/TestHosts/Database/TestBank/Migrations/TestBankContextModelSnapshot.cs b/TestHosts/TestHosts/Migrations/TestBank/Migrations/TestBankContextModelSnapshot.cs similarity index 100% rename from TestHosts/TestHosts/Database/TestBank/Migrations/TestBankContextModelSnapshot.cs rename to TestHosts/TestHosts/Migrations/TestBank/Migrations/TestBankContextModelSnapshot.cs