Skip to content

Commit 63ff306

Browse files
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>
1 parent 3d5f4e8 commit 63ff306

10 files changed

Lines changed: 1572 additions & 7 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,15 @@
11
# TestHosts
2+
3+
## Mobile Wallet test API
4+
5+
The `TestHosts` web application now includes a GSMA-style mobile wallet test API under `mobilemoney/v1_0` with:
6+
7+
- OAuth2 client-credentials token issuance via `POST /oauth/token`
8+
- account creation, lookup, status, balance, and transaction history endpoints
9+
- idempotent transaction and reversal creation using the `X-Idempotency-Key` header
10+
- asynchronous transaction and reversal completion via a background processor
11+
- webhook subscriptions plus per-request callback URLs for event delivery
12+
- audit entries for token, account, transaction, reversal, and webhook operations
13+
- SQL Server or in-memory persistence using the `MobileWalletReadModel` connection string
14+
15+
Default seeded OAuth2 client credentials are configured in `appsettings*.json` under `MobileWallet:OAuth`.

TestHosts/TestHosts/Controllers/MobileWalletController.cs

Lines changed: 855 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
namespace TestHosts.Database.MobileWallet
2+
{
3+
using Microsoft.EntityFrameworkCore;
4+
using Shared.General;
5+
using System;
6+
7+
public class MobileWalletContext : DbContext
8+
{
9+
private readonly String ConnectionString;
10+
11+
public MobileWalletContext()
12+
{
13+
this.ConnectionString = ConfigurationReader.GetConnectionString(Constants.MobileWalletReadModelConfig);
14+
}
15+
16+
public MobileWalletContext(String connectionString)
17+
{
18+
this.ConnectionString = connectionString;
19+
}
20+
21+
public MobileWalletContext(DbContextOptions<MobileWalletContext> dbContextOptions) : base(dbContextOptions)
22+
{
23+
}
24+
25+
public DbSet<MobileWalletClient> Clients { get; set; }
26+
public DbSet<MobileWalletAccessToken> AccessTokens { get; set; }
27+
public DbSet<MobileWalletAccount> Accounts { get; set; }
28+
public DbSet<MobileWalletTransaction> Transactions { get; set; }
29+
public DbSet<MobileWalletReversal> Reversals { get; set; }
30+
public DbSet<MobileWalletWebhookSubscription> WebhookSubscriptions { get; set; }
31+
public DbSet<MobileWalletWebhookDelivery> WebhookDeliveries { get; set; }
32+
public DbSet<MobileWalletAuditEntry> AuditEntries { get; set; }
33+
34+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
35+
{
36+
if (string.IsNullOrWhiteSpace(this.ConnectionString) == false)
37+
{
38+
optionsBuilder.UseSqlServer(this.ConnectionString);
39+
}
40+
41+
base.OnConfiguring(optionsBuilder);
42+
}
43+
44+
protected override void OnModelCreating(ModelBuilder modelBuilder)
45+
{
46+
modelBuilder.Entity<MobileWalletClient>().HasKey(c => c.ClientId);
47+
modelBuilder.Entity<MobileWalletAccessToken>().HasKey(t => t.TokenId);
48+
modelBuilder.Entity<MobileWalletAccessToken>().HasIndex(t => t.Token).IsUnique();
49+
modelBuilder.Entity<MobileWalletAccount>().HasKey(a => a.AccountReference);
50+
modelBuilder.Entity<MobileWalletTransaction>().HasKey(t => t.TransactionReference);
51+
modelBuilder.Entity<MobileWalletTransaction>().HasIndex(t => new { t.ClientId, t.IdempotencyKey }).IsUnique();
52+
modelBuilder.Entity<MobileWalletTransaction>().HasIndex(t => t.ExternalReference);
53+
modelBuilder.Entity<MobileWalletReversal>().HasKey(r => r.ReversalReference);
54+
modelBuilder.Entity<MobileWalletReversal>().HasIndex(r => new { r.ClientId, r.IdempotencyKey }).IsUnique();
55+
modelBuilder.Entity<MobileWalletWebhookSubscription>().HasKey(s => s.SubscriptionId);
56+
modelBuilder.Entity<MobileWalletWebhookDelivery>().HasKey(d => d.DeliveryId);
57+
modelBuilder.Entity<MobileWalletAuditEntry>().HasKey(a => a.AuditEntryId);
58+
59+
base.OnModelCreating(modelBuilder);
60+
}
61+
}
62+
63+
public sealed class MobileWalletClient
64+
{
65+
public String ClientId { get; set; } = String.Empty;
66+
public String ClientSecret { get; set; } = String.Empty;
67+
public String Name { get; set; } = String.Empty;
68+
public String? CallbackUrl { get; set; }
69+
public Boolean IsActive { get; set; }
70+
public DateTime CreatedAt { get; set; }
71+
}
72+
73+
public sealed class MobileWalletAccessToken
74+
{
75+
public Guid TokenId { get; set; }
76+
public String Token { get; set; } = String.Empty;
77+
public String ClientId { get; set; } = String.Empty;
78+
public DateTime ExpiresAt { get; set; }
79+
public DateTime CreatedAt { get; set; }
80+
}
81+
82+
public sealed class MobileWalletAccount
83+
{
84+
public String AccountReference { get; set; } = String.Empty;
85+
public String AccountType { get; set; } = "wallet";
86+
public String Status { get; set; } = "active";
87+
public String Currency { get; set; } = "USD";
88+
public Decimal AvailableBalance { get; set; }
89+
public String GivenName { get; set; } = String.Empty;
90+
public String FamilyName { get; set; } = String.Empty;
91+
public String? Msisdn { get; set; }
92+
public String? EmailAddress { get; set; }
93+
public String KycStatus { get; set; } = "not_provided";
94+
public String? IdentityType { get; set; }
95+
public String? IdentityNumber { get; set; }
96+
public String? DateOfBirth { get; set; }
97+
public String? Nationality { get; set; }
98+
public DateTime CreatedAt { get; set; }
99+
public DateTime UpdatedAt { get; set; }
100+
}
101+
102+
public sealed class MobileWalletTransaction
103+
{
104+
public String TransactionReference { get; set; } = String.Empty;
105+
public String ClientId { get; set; } = String.Empty;
106+
public String TransactionType { get; set; } = String.Empty;
107+
public String Status { get; set; } = "pending";
108+
public String StatusMessage { get; set; } = "Transaction accepted for asynchronous processing.";
109+
public Decimal Amount { get; set; }
110+
public String Currency { get; set; } = "USD";
111+
public String DebitAccountReference { get; set; } = String.Empty;
112+
public String CreditAccountReference { get; set; } = String.Empty;
113+
public String? ExternalReference { get; set; }
114+
public String IdempotencyKey { get; set; } = String.Empty;
115+
public String RequestPayload { get; set; } = String.Empty;
116+
public String? CallbackUrl { get; set; }
117+
public String? MetadataJson { get; set; }
118+
public Boolean BalanceApplied { get; set; }
119+
public DateTime CreatedAt { get; set; }
120+
public DateTime UpdatedAt { get; set; }
121+
public DateTime? CompletedAt { get; set; }
122+
}
123+
124+
public sealed class MobileWalletReversal
125+
{
126+
public String ReversalReference { get; set; } = String.Empty;
127+
public String ClientId { get; set; } = String.Empty;
128+
public String OriginalTransactionReference { get; set; } = String.Empty;
129+
public String Reason { get; set; } = String.Empty;
130+
public String Status { get; set; } = "pending";
131+
public String StatusMessage { get; set; } = "Reversal accepted for asynchronous processing.";
132+
public String IdempotencyKey { get; set; } = String.Empty;
133+
public String RequestPayload { get; set; } = String.Empty;
134+
public DateTime CreatedAt { get; set; }
135+
public DateTime UpdatedAt { get; set; }
136+
public DateTime? CompletedAt { get; set; }
137+
}
138+
139+
public sealed class MobileWalletWebhookSubscription
140+
{
141+
public Guid SubscriptionId { get; set; }
142+
public String ClientId { get; set; } = String.Empty;
143+
public String EventType { get; set; } = "*";
144+
public String CallbackUrl { get; set; } = String.Empty;
145+
public Boolean IsActive { get; set; }
146+
public DateTime CreatedAt { get; set; }
147+
}
148+
149+
public sealed class MobileWalletWebhookDelivery
150+
{
151+
public Guid DeliveryId { get; set; }
152+
public String ClientId { get; set; } = String.Empty;
153+
public String EventType { get; set; } = String.Empty;
154+
public String ResourceReference { get; set; } = String.Empty;
155+
public String CallbackUrl { get; set; } = String.Empty;
156+
public String Payload { get; set; } = String.Empty;
157+
public String Status { get; set; } = "pending";
158+
public Int32 AttemptCount { get; set; }
159+
public DateTime CreatedAt { get; set; }
160+
public DateTime? LastAttemptAt { get; set; }
161+
public DateTime? DeliveredAt { get; set; }
162+
public String? LastError { get; set; }
163+
}
164+
165+
public sealed class MobileWalletAuditEntry
166+
{
167+
public Guid AuditEntryId { get; set; }
168+
public String ResourceType { get; set; } = String.Empty;
169+
public String ResourceReference { get; set; } = String.Empty;
170+
public String Action { get; set; } = String.Empty;
171+
public String Actor { get; set; } = String.Empty;
172+
public String HttpMethod { get; set; } = String.Empty;
173+
public String Path { get; set; } = String.Empty;
174+
public String? IdempotencyKey { get; set; }
175+
public String? RequestPayload { get; set; }
176+
public String? ResponsePayload { get; set; }
177+
public DateTime CreatedAt { get; set; }
178+
}
179+
}

TestHosts/TestHosts/Program.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
using TestHosts.Common;
3232
using TestHosts.Database.PataPawa;
3333
using TestHosts.Database.TestBank;
34+
using TestHosts.Database.MobileWallet;
3435
using TestHosts.SoapServices;
36+
using TestHosts.Services.MobileWallet;
3537
using ILogger = Microsoft.Extensions.Logging.ILogger;
3638
using LogLevel = Microsoft.Extensions.Logging.LogLevel;
3739

@@ -108,6 +110,7 @@
108110
if (builder.Environment.IsEnvironment("IntegrationTest") || builder.Configuration.GetValue<Boolean>("ServiceOptions:UseInMemoryDatabase") == true) {
109111
builder.Services.AddDbContext<TestBankContext>(builder => builder.UseInMemoryDatabase(Constants.TestBankReadModelConfig));
110112
builder.Services.AddDbContext<PataPawaContext>(builder => builder.UseInMemoryDatabase(Constants.PataPawaReadModelConfig));
113+
builder.Services.AddDbContext<MobileWalletContext>(builder => builder.UseInMemoryDatabase(Constants.MobileWalletReadModelConfig));
111114

112115
}
113116
else {
@@ -116,8 +119,13 @@
116119

117120
String pataPawaConnectionString = ConfigurationReader.GetConnectionString(Constants.PataPawaReadModelConfig);
118121
builder.Services.AddDbContext<PataPawaContext>(builder => builder.UseSqlServer(pataPawaConnectionString));
122+
123+
String mobileWalletConnectionString = ConfigurationReader.GetConnectionString(Constants.MobileWalletReadModelConfig);
124+
builder.Services.AddDbContext<MobileWalletContext>(builder => builder.UseSqlServer(mobileWalletConnectionString));
119125
}
120126

127+
builder.Services.AddHttpClient();
128+
builder.Services.AddScoped<MobileWalletService>();
121129
builder.Services.AddScoped<TenantContext>(x => new TenantContext());
122130
builder.Services.AddSingleton<PataPawaPostPayService>();
123131
// Add your background hosted service
@@ -128,6 +136,7 @@
128136

129137
// Database initialization will now be handled by a hosted service
130138
builder.Services.AddHostedService<DatabaseInitializerHostedService>();
139+
builder.Services.AddHostedService<MobileWalletBackgroundService>();
131140

132141
builder.Services.AddScoped<TenantContext>(x => new TenantContext());
133142
builder.Services.AddSingleton<PataPawaPostPayService>();
@@ -208,4 +217,5 @@
208217
public static class Constants {
209218
public static readonly String PataPawaReadModelConfig = "PataPawaReadModel";
210219
public static readonly String TestBankReadModelConfig = "TestBankReadModel";
211-
}
220+
public static readonly String MobileWalletReadModelConfig = "MobileWalletReadModel";
221+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
namespace TestHosts.Services.MobileWallet
2+
{
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Microsoft.Extensions.Hosting;
5+
using System;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
9+
public sealed class MobileWalletBackgroundService : BackgroundService
10+
{
11+
private readonly IServiceScopeFactory ScopeFactory;
12+
13+
public MobileWalletBackgroundService(IServiceScopeFactory scopeFactory)
14+
{
15+
this.ScopeFactory = scopeFactory;
16+
}
17+
18+
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
19+
{
20+
while (stoppingToken.IsCancellationRequested == false)
21+
{
22+
try
23+
{
24+
using IServiceScope scope = this.ScopeFactory.CreateScope();
25+
MobileWalletService service = scope.ServiceProvider.GetRequiredService<MobileWalletService>();
26+
await service.ProcessPendingTransactionsAsync(stoppingToken);
27+
await service.ProcessPendingReversalsAsync(stoppingToken);
28+
await service.DeliverPendingWebhooksAsync(stoppingToken);
29+
}
30+
catch
31+
{
32+
}
33+
34+
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
35+
}
36+
}
37+
}
38+
}

0 commit comments

Comments
 (0)