using GuestOps.Web; using Microsoft.Extensions.Configuration; using System.Net; using System.Text; using System.Text.Json.Nodes; public static class PaymentTests { public static async Task Run(Action check,IStore store) { var hotel=new Hotel{PaymentsEnabled=true};hotel.HotelId=hotel.Id;await store.Insert(hotel); var prefix="Payments:Hotels:"+hotel.Id+":"; var config=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary{{prefix+"BaseUrl","https://sandbox.nmi.com"},{prefix+"MerchantAccount","test-merchant"},{prefix+"SecurityKey","fake-test-key"},{prefix+"CreatesEnabled","true"}}).Build(); var handler=new Fixture();var work=new PaymentWork(store,new NmiInvoices(new HttpClient(handler)),config); int counter=0; Task Propose()=>work.Propose(hotel.Id,"owner",new("TEST-"+(++counter),"guest@example.invalid","Booking deposit",80.25m,"GBP")); var p=await Propose(); check("Payment proposal is durable without contacting NMI",handler.Posts==0&&handler.Reads==0&&(await store.Get(hotel.Id,p.Id))?.Amount==80.25m); check("Duplicate payment reference is rejected",await Blocked(()=>work.Propose(hotel.Id,"owner",new(p.Reference.ToLowerInvariant(),p.Email,p.Description,p.Amount,p.Currency)))); check("Other hotels cannot read payment requests",await store.Get("other",p.Id)==null); check("Currency and fractional penny amounts are rejected",await Blocked(()=>work.Propose(hotel.Id,"owner",new("BAD","guest@example.invalid","Deposit",1.001m,"GBP")))&&await Blocked(()=>work.Propose(hotel.Id,"owner",new("BAD","guest@example.invalid","Deposit",1m,"JPY")))); check("Payment email injection and display-name addresses are rejected",await Blocked(()=>work.Propose(hotel.Id,"owner",new("BAD","Guest ","Deposit",1m,"GBP")))&&await Blocked(()=>work.Propose(hotel.Id,"owner",new("BAD","guest@example.invalid\r\nBcc: other@example.invalid","Deposit",1m,"GBP")))); check("Creating an invoice requires email and amount approval",await Blocked(()=>work.Create(p,0,"owner",false,default))&&handler.Posts==0); check("Payment approval rejects stale versions",await Blocked(()=>work.Create(p,5,"owner",true,default))&&handler.Posts==0); var one=(await store.Get(hotel.Id,p.Id))!;var two=(await store.Get(hotel.Id,p.Id))!; await Task.WhenAll(Blocked(()=>work.Create(one,0,"owner",true,default)),Blocked(()=>work.Create(two,0,"owner",true,default))); p=(await store.Get(hotel.Id,p.Id))!; check("Concurrent payment approvals create one invoice",handler.Posts==1&&p.State=="Open"); check("NMI payload preserves exact amount and correlation",handler.Last!["amount"]!.GetValue()==80.25m&&handler.Last["currency"]!.GetValue()=="GBP"&&handler.Last["order_details"]!["order_id"]!.GetValue()==p.Id); check("Invoice creation never calls a separate email endpoint",handler.Paths.All(x=>!x.EndsWith("/send"))); check("Completed invoice creation cannot be replayed",await Blocked(()=>work.Create(p,p.Version,"owner",true,default))&&handler.Posts==1); handler.Invoice!["status"]="partially_paid";p=await work.Check(p,p.Version,default);check("Partial invoice remains partial",p.State=="Partial"); handler.Invoice["status"]="paid";p=await work.Check(p,p.Version,default);check("Matching NMI invoice can be observed paid",p.State=="Paid"&&p.CheckedAt!=null); handler.Invoice["amount"]="80.24";p=await work.Check(p,p.Version,default);check("Paid status cannot bypass amount verification",p.State=="NeedsReview");handler.Invoice["amount"]="80.25"; handler.Invoice["currency"]="USD";p=await work.Check(p,p.Version,default);check("Paid status cannot bypass currency verification",p.State=="NeedsReview");handler.Invoice["currency"]="GBP"; handler.Invoice["billing_address"]!["email"]="other@example.invalid";p=await work.Check(p,p.Version,default);check("Payment recipient mismatch stays held",p.State=="NeedsReview");handler.Invoice["billing_address"]!["email"]="guest@example.invalid"; handler.Invoice["order_details"]!["order_id"]="other-request";p=await work.Check(p,p.Version,default);check("Payment order identity mismatch stays held",p.State=="NeedsReview");handler.Invoice["order_details"]!["order_id"]=p.Id; handler.Invoice["id"]=99999;p=await work.Check(p,p.Version,default);check("Payment invoice ID mismatch stays held",p.State=="NeedsReview");handler.Invoice["id"]=int.Parse(p.InvoiceId); handler.Invoice["status"]="unknown";p=await work.Check(p,p.Version,default);check("Unknown invoice status is not accepted",p.State=="NeedsReview");handler.Invoice["status"]="paid"; config[prefix+"CreatesEnabled"]="false";p=await work.Check(p,p.Version,default);check("Read-only payment verification works with creation disabled",p.State=="Paid");config[prefix+"CreatesEnabled"]="true"; config[prefix+"SecurityKey"]="rotated-fake-key";p=await work.Check(p,p.Version,default);check("Key rotation preserves same-merchant reconciliation",p.State=="Paid"); config[prefix+"MerchantAccount"]="different-merchant";check("Changed merchant binding blocks payment reconciliation",await Blocked(()=>work.Check(p,p.Version,default)));config[prefix+"MerchantAccount"]="test-merchant"; p=await Propose();handler.TimeoutAfterCreate=true;p=await work.Create(p,0,"owner",true,default);handler.TimeoutAfterCreate=false;int posts=handler.Posts; check("Timed-out invoice creation is held without retry",p.State=="NeedsReview"&&p.InvoiceId==""&&await Blocked(()=>work.Create(p,p.Version,"owner",true,default))&&handler.Posts==posts); handler.DuplicateSearch=true;p=await work.Check(p,p.Version,default);check("Ambiguous invoice recovery stays held",p.State=="NeedsReview");handler.DuplicateSearch=false; handler.IncompleteSearch=true;p=await work.Check(p,p.Version,default);check("Incomplete invoice pagination cannot reconcile",p.State=="NeedsReview");handler.IncompleteSearch=false; handler.EmptySearch=true;p=await work.Check(p,p.Version,default);check("Missing invoice recovery never authorizes another creation",p.State=="NeedsReview"&&handler.Posts==posts);handler.EmptySearch=false; p=await work.Check(p,p.Version,default);check("Lost create response reconciles by exact order ID with reads only",p.State=="Open"&&p.InvoiceId.Length>0&&handler.Posts==posts); handler.FailRead=true;p=await work.Check(p,p.Version,default);check("Provider read failure records a held state",p.State=="NeedsReview");handler.FailRead=false; p=await Propose();handler.TimeoutAfterCreate=true;p=await work.Create(p,0,"owner",true,default);handler.TimeoutAfterCreate=false; var v=p.Version;p.State="Creating";p.UpdatedAt=DateTime.UtcNow;p.Version++;await store.Replace(hotel.Id,p.Id,v,p); check("Interrupted invoice is not reconciled during active deadline",await Blocked(()=>work.Check(p,p.Version,default))); v=p.Version;p.UpdatedAt=DateTime.UtcNow.AddMinutes(-6);p.Version++;await store.Replace(hotel.Id,p.Id,v,p);p=await work.Check(p,p.Version,default);check("Interrupted invoice can reconcile after restart",p.State=="Open"); p=await Propose();p=await work.Cancel(p,0);check("Only unsubmitted payment proposals can be cancelled",p.State=="Cancelled"&&await Blocked(()=>work.Create(p,p.Version,"owner",true,default))); p=await Propose();p.ExpiresAt=DateTime.UtcNow.AddSeconds(-1);check("Expired payment approval is blocked",await Blocked(()=>work.Create(p,0,"owner",true,default))); p=await Propose();hotel.PaymentsEnabled=false;hotel.Version++;await store.Replace(hotel.Id,hotel.Id,hotel.Version-1,hotel);check("Hotel payment stop control blocks invoice creation",await Blocked(()=>work.Create(p,0,"owner",true,default))); check("Unconfigured hotel cannot use another merchant",NmiProfile.Read(config,Guid.NewGuid().ToString("N"))==null); config[prefix+"BaseUrl"]="https://evil.example.invalid";check("NMI origin is restricted to known provider hosts",await Blocked(()=>Task.FromResult(NmiProfile.Read(config,hotel.Id)))); } static async Task Blocked(Func action){try{await action();return false;}catch(PaymentInvalid){return true;}catch(PaymentConflict){return true;}} sealed class Fixture:HttpMessageHandler { public int Posts,Reads;public bool TimeoutAfterCreate,DuplicateSearch,IncompleteSearch,EmptySearch,FailRead; public JsonNode? Last,Invoice;public List Paths=[]; protected override async Task SendAsync(HttpRequestMessage request,CancellationToken ct) { if(request.RequestUri?.Host!="sandbox.nmi.com")throw new InvalidOperationException("Only fake NMI requests are permitted by this handler."); var path=request.RequestUri.AbsolutePath;Paths.Add(path); if(request.Method==HttpMethod.Post) { if(path!="/api/v5/invoices")throw new InvalidOperationException("Unexpected external write."); Posts++;Last=JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct)); Invoice=new JsonObject{["object"]="invoice",["id"]=1000+Posts,["status"]="open",["amount"]=Last!["amount"]!.ToString(),["currency"]=Last["currency"]!.DeepClone(),["billing_address"]=Last["billing_address"]!.DeepClone(),["order_details"]=Last["order_details"]!.DeepClone()}; if(TimeoutAfterCreate)throw new TaskCanceledException("Simulated lost response");return Response(Invoice); } if(request.Method!=HttpMethod.Get)throw new InvalidOperationException("Unexpected external mutation.");Reads++; if(FailRead)return new(HttpStatusCode.ServiceUnavailable); if(path=="/api/v5/invoices")return Response(new JsonObject{["invoices"]=EmptySearch?new JsonArray():DuplicateSearch?new JsonArray(Invoice!.DeepClone(),Invoice!.DeepClone()):new JsonArray(Invoice!.DeepClone()),["next_cursor"]=IncompleteSearch?123:null}); return Response(Invoice!); } static HttpResponseMessage Response(JsonNode n)=>new(HttpStatusCode.OK){Content=new StringContent(n.ToJsonString(),Encoding.UTF8,"application/json")}; } }