From ad57a8b1bdc621791a77cbac5d883d963e5983d4 Mon Sep 17 00:00:00 2001 From: wolf-demon Date: Tue, 15 Sep 2026 09:31:05 +0100 Subject: [PATCH] Add reviewed NMI hosted invoices and read-only payment reconciliation --- .dockerignore | 2 + .env.example | 3 + .gitignore | 2 + README.md | 8 +- compose.yml | 6 +- deploy/payments.example.json | 1 + docs/migration.md | 7 +- docs/payments.md | 51 ++++++++++ src/GuestOps.Api/Demo.cs | 1 + src/GuestOps.Api/Models.cs | 1 + src/GuestOps.Api/PaymentEndpoints.cs | 32 ++++++ src/GuestOps.Api/Payments.cs | 147 +++++++++++++++++++++++++++ src/GuestOps.Api/Program.cs | 6 ++ src/GuestOps.Api/Store.cs | 18 +++- tests/GuestOps.Tests/PaymentTests.cs | 82 +++++++++++++++ tests/GuestOps.Tests/Program.cs | 13 ++- tests/production_smoke.py | 5 + web/src/PaymentsPage.tsx | 27 +++++ web/src/api.ts | 2 +- web/src/main.tsx | 9 +- 20 files changed, 411 insertions(+), 12 deletions(-) create mode 100644 deploy/payments.example.json create mode 100644 docs/payments.md create mode 100644 src/GuestOps.Api/PaymentEndpoints.cs create mode 100644 src/GuestOps.Api/Payments.cs create mode 100644 tests/GuestOps.Tests/PaymentTests.cs create mode 100644 web/src/PaymentsPage.tsx diff --git a/.dockerignore b/.dockerignore index cc301dc..cdd2bbe 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,3 +9,5 @@ tests *.tar* **/pms.local.json + +**/payments.local.json diff --git a/.env.example b/.env.example index 3841e1d..af12d83 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,6 @@ PMS_CONFIG_FILE_HOST=./deploy/pms.example.json # CI produces image archives. Set these to the loaded, reviewed commit tags. GUESTOPS_API_IMAGE=guestops-api:local GUESTOPS_WORKER_IMAGE=guestops-worker:local + +# Private NMI configuration; leave the empty example until sandbox setup. +PAYMENTS_CONFIG_FILE_HOST=./deploy/payments.example.json diff --git a/.gitignore b/.gitignore index 8cfb7a4..861da9c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ *.tar.gz .DS_Store **/pms.local.json + +**/payments.local.json diff --git a/README.md b/README.md index 269464e..6181401 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GuestOps Web -A Linux-hosted hotel email workspace, developed separately from the Windows GuestOps application. **This migration now includes AI draft generation, staff-approved Gmail sending and reviewed OHIP reservation updates. It is not yet a production-complete replacement.** +A Linux-hosted hotel email workspace, developed separately from the Windows GuestOps application. **This migration now includes AI draft generation, staff-approved Gmail sending reviewed OHIP reservation updates and NMI hosted invoices. It is not yet a production-complete replacement.** ## Implemented so far @@ -11,11 +11,12 @@ A Linux-hosted hotel email workspace, developed separately from the Windows Gues - Staff-approved Gmail replies with immutable approval snapshots, duplicate prevention and uncertain-delivery verification. Both AI and sending are off by default per hotel. - Google OAuth connection and a separate Gmail import/delivery worker. The first import covers seven days of inbox messages; paginated checkpoints and duplicate suppression survive restarts. - Windows-independent booking model, validation, email cleaning, JSON extraction and secret redaction migrated from the hardened desktop code. OHIP exact reservation lookup, internal notes and owner-approved stay-date changes are implemented with durable review and read-only reconciliation; writes are off by default. +- Owner-reviewed NMI invoice creation, tenant-specific merchant configuration and read-only status/recovery checks. Creation may email the customer a hosted payment link through NMI; it is off by default. - Docker image builds, private MongoDB configuration and an Nginx HTTPS example for the Debian sandbox. ## Explicit limits -Real email is sent only after server configuration, Google send consent, hotel-owner opt-in and explicit staff approval of a saved reply. Automatic sending, wider PMS workflows, payment links, staff invitation/password-reset UI, attachments and complete Gmail-thread aggregation are follow-on work. There is no public registration endpoint. Initial hotel owners are provisioned by the server administrator. +Real email is sent only after server configuration, Google send consent, hotel-owner opt-in and explicit staff approval of a saved reply. Automatic sending, wider PMS workflows, direct payment URLs in replies, staff invitation/password-reset UI, attachments and complete Gmail-thread aggregation are follow-on work. There is no public registration endpoint. Initial hotel owners are provisioned by the server administrator. The Google integration needs OAuth credentials and a sandbox mailbox before its live behaviour can be accepted. Automated tests do not access Gmail or a hotel system. An integration being implemented is not a claim of Google verification or production readiness. @@ -58,5 +59,6 @@ cd web && npm ci && npm run build Set `MONGO_TEST_URI` to an isolated MongoDB server and `TEST_API_URL=http://127.0.0.1:5180` with a preview API running to enable database and HTTP integration checks. The suite creates and drops only its own randomly named `guestops_test_*` database. CI runs both integrations and builds both Linux images. -See [OHIP reservation setup and recovery](docs/pms.md), [AI drafts and reply delivery setup](docs/replies.md), [migration status](docs/migration.md) and [deployment guide](docs/deployment.md). +See [NMI payment setup and recovery](docs/payments.md), [OHIP reservation setup and recovery](docs/pms.md), [AI drafts and reply delivery setup](docs/replies.md), [migration status](docs/migration.md) and [deployment guide](docs/deployment.md). + diff --git a/compose.yml b/compose.yml index f61bc18..c9dd240 100644 --- a/compose.yml +++ b/compose.yml @@ -25,8 +25,12 @@ services: Proxy__KnownAddress: ${GUESTOPS_GATEWAY:-172.30.87.1} Ai__ApiKey: ${AI_API_KEY:-} Ai__Model: ${AI_MODEL:-} + Payments__ConfigFile: /run/guestops/payments.json Pms__ConfigFile: /run/guestops/pms.json - volumes: ["app-keys:/var/lib/guestops/keys", "${PMS_CONFIG_FILE_HOST:-./deploy/pms.example.json}:/run/guestops/pms.json:ro"] + volumes: + - app-keys:/var/lib/guestops/keys + - ${PMS_CONFIG_FILE_HOST:-./deploy/pms.example.json}:/run/guestops/pms.json:ro + - ${PAYMENTS_CONFIG_FILE_HOST:-./deploy/payments.example.json}:/run/guestops/payments.json:ro depends_on: mongo: { condition: service_healthy } logging: *logging diff --git a/deploy/payments.example.json b/deploy/payments.example.json new file mode 100644 index 0000000..9f8aa13 --- /dev/null +++ b/deploy/payments.example.json @@ -0,0 +1 @@ +{ "Payments": { "Hotels": {} } } diff --git a/docs/migration.md b/docs/migration.md index d220889..347eefe 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -24,13 +24,17 @@ Implemented optional OpenAI draft generation, validated hotel answer references, Implemented exact confirmation lookup, internal notes and owner-approved stay-date changes, with tenant-specific server credentials, MongoDB proposals, duplicate approval prevention, stale-booking checks and read-only recovery of uncertain results. Live OHIP sandbox acceptance is pending; writes remain off by default. See [PMS setup and limitations](pms.md). +## Milestone 4: NMI hosted invoices and reconciliation + +Implemented owner-reviewed invoice creation, unique payment references, customer-email approval, tenant-specific merchant configuration, partial/paid invoice status and read-only recovery after lost responses. The hosted payment link is delivered by NMI's invoice email; the published API does not guarantee a URL for insertion into GuestOps replies. Live sandbox acceptance remains pending. See [payment setup and limits](payments.md). + ## Remaining milestones 1. Test Google connection with a dedicated test mailbox; add provider fixture tests, disconnect/revocation, refresh failure recovery and full thread aggregation. 2. Add staff invitations, account recovery, granular roles, user preferences and an onboarding wizard. 3. Run a representative live AI draft evaluation, improve retrieval and evidence presentation, and approve the selected provider data-processing arrangements. 4. Extend the implemented durable reply queue with operator recovery tooling and guarded FAQ auto-replies after live acceptance. Preserve the rule that uncertain sends are never blindly replayed. -5. Validate the OHIP adapter against the property sandbox, extend supported PMS operations and implement payment links with provider sandbox tests and reconciliation. Do not enable these by merely copying desktop settings or toggling a feature flag. +5. Validate the OHIP adapter against the property sandbox, extend supported PMS operations and validate NMI hosted invoices with the merchant sandbox. Add direct payment URLs only when a supported provider contract is available. Do not enable these by merely copying desktop settings or toggling a feature flag. Windows DPAPI settings must not be copied to Linux as usable credentials. Re-enter provider credentials through the server setup and reauthorize each mailbox. No existing hotel data has been transferred. @@ -38,3 +42,4 @@ Windows DPAPI settings must not be copied to Linux as usable credentials. Re-ent Target supplied by the owner: Debian 12, 4 CPU cores, 7.6 GiB RAM, 18 GiB free disk; MongoDB on the same machine. Compose includes conservative starting memory limits and capped logs, not a capacity guarantee. Keep database/key backups off-server and do not import entire mailboxes by default. Establish retention and restore testing before real guest data is used. + diff --git a/docs/payments.md b/docs/payments.md new file mode 100644 index 0000000..1356b52 --- /dev/null +++ b/docs/payments.md @@ -0,0 +1,51 @@ +# NMI hosted payment requests + +The Payments page prepares a reviewed invoice, creates it once through NMI, and checks its status. MongoDB stores each hotel's enablement setting, immutable proposal details and the latest verification result. Invoice creation and verification require an owner account. Provider credentials stay on the server. + +## Configure a sandbox merchant + +Copy `deploy/payments.example.json` to a private `payments.local.json` outside the checkout: + +```json +{ + "Payments": { + "Hotels": { + "REPLACE_WITH_INTERNAL_GUESTOPS_HOTEL_ID": { + "BaseUrl": "https://sandbox.nmi.com", + "MerchantAccount": "YOUR_STABLE_MERCHANT_ACCOUNT_ID", + "SecurityKey": "YOUR_NMI_V5_MERCHANT_KEY", + "CreatesEnabled": false + } + } + } +} +``` + +Use the internal 32-character hotel ID shown on the Payments page. `MerchantAccount` is an administrator-maintained identity binding: verify it against the merchant account behind the key. Do not reuse the same identity for a different merchant. The API accepts only `https://sandbox.nmi.com` and `https://secure.nmi.com`; confirm the correct environment and key with NMI. + +Set `PAYMENTS_CONFIG_FILE_HOST` in the deployment `.env` to the absolute private file path. Compose mounts it read-only in the API only. Restrict file permissions to the administrator and the container user/group that needs read access. Determine the image's user with `docker run --rm --entrypoint id YOUR_API_IMAGE -u` before assigning permissions. Restart the API after changes. The default empty example disables the integration. Do not commit private configuration or put keys in the browser. + +After sandbox acceptance, enable `CreatesEnabled` on the server and **Allow owner-approved payment invoices** in the hotel's Payments page. Both controls are required. Turning off creation leaves read-only reconciliation available. Key rotation preserves reconciliation for the same merchant binding; pending approvals require the original credential revision and must be replaced after rotation. Changing the merchant identity blocks reconciliation until the original binding is restored. + +## Staff workflow + +1. Enter a unique payment reference, customer email, description, amount and currency. This milestone supports GBP, EUR and USD, with two decimal places and a maximum of 100,000 per invoice. Confirm the merchant supports the chosen currency. +2. Prepare the request. This only writes a proposal to MongoDB. Check the guest's agreed amount and booking terms separately; preparation does not reserve inventory. +3. Review and approve the amount, currency, recipient and customer email. Approval expires after fifteen minutes. Creating an NMI invoice can email the customer a hosted payment link. GuestOps does not call a separate send endpoint. +4. Use **Verify with NMI** to refresh invoice status. It verifies invoice ID, order reference, recipient, amount and currency before accepting a known status. Partial payment is displayed separately. `Paid` means NMI reports the invoice paid; it is not proof of bank settlement and does not create or update a booking. + +NMI's published [Create Invoice](https://docs.nmi.com/reference/create-invoice-v5) and [Get Invoice](https://docs.nmi.com/reference/get-invoice-v5) schemas were inspected on 2026-09-14. The published InvoiceResponse does not guarantee a `payment_url`. This implementation relies on NMI's hosted invoice email; it does not construct checkout URLs or insert a payment URL into Gmail drafts. Confirm invoice email delivery and hosted checkout with your sandbox merchant before enabling live creation. + +## Interrupted requests and reconciliation + +Every request has a unique hotel/payment reference enforced by MongoDB, including cancelled requests. Concurrent approvals create at most one local submission. No provider idempotency guarantee is assumed. A timeout or ambiguous response remains `NeedsReview` and creation cannot be repeated. Do not work around an uncertain result by inventing a new reference. + +Verification is read-only. If the invoice ID was lost, GuestOps searches NMI by the server-generated order ID, starting one day before proposal creation, with at most ten pages of 100 invoices. An incomplete search, duplicate matches, missing invoice or mismatched details stays held for merchant-portal investigation. Interrupted `Creating` requests become eligible after five minutes; active requests have a ninety-second deadline. No automatic polling, invoice recreation, email retry or force-clear is implemented. + +Only unsubmitted proposals can be cancelled in GuestOps. Invoice closure, refunds, disputes and settlement reconciliation are handled in the NMI merchant portal. Keep the operation reference and invoice ID when investigating. The latest 500 local records are shown; records are retained rather than automatically deleted. Status is an observation at the displayed verification time, not a live balance. + +## Acceptance before live use + +Automated tests use an in-process fake HTTP handler and never contact NMI. They cover tenant isolation, amount/currency/identity mismatches, partial status, concurrent approvals, lost create responses, pagination and merchant changes. CI checks the production configuration mount and disabled defaults. + +Use a dedicated sandbox merchant and recipient to validate authentication, supported currency, exact request/response fields, preservation of `order_details.order_id`, customer invoice email and hosted checkout, partial/full payments and interrupted-request recovery. Live acceptance remains pending. Planet, direct payment links in GuestOps replies, automatic booking after payment, automated expiry/closure and background polling are follow-on work. diff --git a/src/GuestOps.Api/Demo.cs b/src/GuestOps.Api/Demo.cs index d1c7ad2..88538b9 100644 --- a/src/GuestOps.Api/Demo.cs +++ b/src/GuestOps.Api/Demo.cs @@ -19,6 +19,7 @@ public static class Demo await store.Insert(new Activity { HotelId = hotel.Id, UserName = "GuestOps", Action = "Opened an isolated preview workspace" }); var sample = new PmsSnapshot { HotelId=hotel.Id,ReservationId="sample-reservation",Confirmation="WH-2481",GuestName="Oliver Brooks",Arrival=DateTime.UtcNow.AddDays(10).ToString("yyyy-MM-dd"),Departure=DateTime.UtcNow.AddDays(12).ToString("yyyy-MM-dd"),RoomType="Garden King",Status="Reserved",Total="320 GBP" }; await store.Insert(new PmsChange { HotelId=hotel.Id,ReservationId=sample.ReservationId,Before=sample,Kind="StayDates",Arrival=DateTime.UtcNow.AddDays(17).ToString("yyyy-MM-dd"),Departure=DateTime.UtcNow.AddDays(19).ToString("yyyy-MM-dd"),ProposedBy=user.Id,Detail="Sample proposal for interface review only. No PMS connection or update is available in this workspace." }); + await store.Insert(new PaymentRequest {HotelId=hotel.Id,Reference="WH-2481-DEPOSIT",Email="guest@example.invalid",Description="Deposit for Oliver Brooks · WH-2481",Amount=80,Currency="GBP",ProposedBy=user.Id,Detail="Sample proposal only. No invoice or email can be created in preview."}); return user; } } diff --git a/src/GuestOps.Api/Models.cs b/src/GuestOps.Api/Models.cs index d4846e6..41c8efb 100644 --- a/src/GuestOps.Api/Models.cs +++ b/src/GuestOps.Api/Models.cs @@ -9,6 +9,7 @@ public abstract class TenantDocument : ITenantDocument } public class Hotel : TenantDocument { + public bool PaymentsEnabled { get; set; } public bool PmsUpdatesEnabled { get; set; } public bool AiDraftsEnabled { get; set; } public bool StaffSendingEnabled { get; set; } diff --git a/src/GuestOps.Api/PaymentEndpoints.cs b/src/GuestOps.Api/PaymentEndpoints.cs new file mode 100644 index 0000000..fa6f727 --- /dev/null +++ b/src/GuestOps.Api/PaymentEndpoints.cs @@ -0,0 +1,32 @@ +using System.Security.Claims; +namespace GuestOps.Web; +public static class PaymentEndpoints +{ + public static void Map(RouteGroupBuilder api,bool preview) + { + var group=api.MapGroup("/payments").RequireRateLimiting("pms"); + group.MapGet("/status",(HttpContext c,IConfiguration config)=>{var p=preview?null:NmiProfile.Read(config,Session.Hotel(c));return Results.Ok(new{configured=p!=null,createsConfigured=p?.CreatesEnabled==true,sandbox=p?.BaseUrl=="https://sandbox.nmi.com",preview});}); + group.MapGet("/requests",async(HttpContext c,IStore store)=>Results.Ok((await store.List(Session.Hotel(c))).OrderByDescending(p=>p.UpdatedAt).Select(p=>p.View()))); + group.MapPost("/requests",async(PaymentInput input,HttpContext c,PaymentWork work,IStore store)=>{ + if(preview)return Results.BadRequest(new{error="Real payment creation is disabled in preview."}); + var p=await work.Propose(Session.Hotel(c),c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,input);await Session.Audit(store,c,"Prepared payment request for review");return Results.Ok(p.View()); + }).RequireAuthorization("Owner"); + group.MapPost("/requests/{id}/create",async(string id,PaymentApproval input,HttpContext c,PaymentWork work,IStore store)=>{ + var p=await store.Get(Session.Hotel(c),id);if(p==null)return Results.NotFound();if(preview)return Results.BadRequest(); + var result=await work.Create(p,input.Version,c.User.FindFirstValue(ClaimTypes.NameIdentifier)!,input.EmailAndAmountApproved,c.RequestAborted);await Session.Audit(store,c,"Payment creation result: "+result.State);return Results.Ok(result.View()); + }).RequireAuthorization("Owner"); + group.MapPost("/requests/{id}/check",async(string id,VersionInput input,HttpContext c,PaymentWork work,IStore store)=>{ + var p=await store.Get(Session.Hotel(c),id);if(p==null)return Results.NotFound();if(preview)return Results.BadRequest(); + var result=await work.Check(p,input.Version,c.RequestAborted);await Session.Audit(store,c,"Checked payment invoice: "+result.State);return Results.Ok(result.View()); + }).RequireAuthorization("Owner"); + group.MapPost("/requests/{id}/cancel",async(string id,VersionInput input,HttpContext c,PaymentWork work,IStore store)=>{ + var p=await store.Get(Session.Hotel(c),id);if(p==null)return Results.NotFound();if(preview)return Results.BadRequest(); + var result=await work.Cancel(p,input.Version);await Session.Audit(store,c,"Cancelled unsubmitted payment proposal");return Results.Ok(result.View()); + }).RequireAuthorization("Owner"); + group.MapPut("/controls",async(PmsControlsInput input,HttpContext c,IStore store,IConfiguration config)=>{ + if(input.Enabled&&(preview||NmiProfile.Read(config,Session.Hotel(c))?.CreatesEnabled!=true))return Results.BadRequest(new{error="Administrator payment enablement and sandbox acceptance are required first."}); + var hotel=await store.Get(Session.Hotel(c),Session.Hotel(c));if(hotel==null)return Results.NotFound();hotel.PaymentsEnabled=input.Enabled;hotel.Version=input.Version+1; + if(!await store.Replace(hotel.HotelId,hotel.Id,input.Version,hotel))return Input.Conflict();await Session.Audit(store,c,"Updated payment creation control");return Results.Ok(hotel); + }).RequireAuthorization("Owner"); + } +} diff --git a/src/GuestOps.Api/Payments.cs b/src/GuestOps.Api/Payments.cs new file mode 100644 index 0000000..5b32a5b --- /dev/null +++ b/src/GuestOps.Api/Payments.cs @@ -0,0 +1,147 @@ +using System.Globalization; +using System.Net.Mail; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +namespace GuestOps.Web; + +public sealed class PaymentRequest : TenantDocument +{ + public string Reference { get; set; } = ""; + public string Email { get; set; } = ""; + public string Description { get; set; } = ""; + public decimal Amount { get; set; } + public string Currency { get; set; } = "GBP"; + public string State { get; set; } = "Review"; + public string Detail { get; set; } = "Review amount, currency and recipient before creating the invoice."; + public string InvoiceId { get; set; } = ""; + public string Binding { get; set; } = ""; + public string Revision { get; set; } = ""; + public string ProposedBy { get; set; } = ""; + public string ApprovedBy { get; set; } = ""; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime? CheckedAt { get; set; } + public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddMinutes(15); + public long Version { get; set; } + public object View()=>new {Id,Reference,Email,Description,Amount,Currency,State,Detail,InvoiceId,ProposedBy,ApprovedBy,CreatedAt,UpdatedAt,CheckedAt,ExpiresAt,Version}; +} +public sealed record PaymentInput(string Reference,string Email,string Description,decimal Amount,string Currency); +public sealed record PaymentApproval(long Version,bool EmailAndAmountApproved); +public sealed class PaymentInvalid(string message):Exception(message); +public sealed class PaymentConflict(string message):Exception(message); +public sealed class NmiProfile +{ + public string BaseUrl {get;set;}="https://sandbox.nmi.com"; + public string MerchantAccount {get;set;}=""; + public string SecurityKey {get;set;}=""; + public bool CreatesEnabled {get;set;} + public string Binding=>Hash(BaseUrl+"|"+MerchantAccount); + public string Revision=>Hash(Binding+"|"+SecurityKey); + static string Hash(string s)=>Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(s))); + public static NmiProfile? Read(IConfiguration config,string hotel) + { + if(!Regex.IsMatch(hotel,"^[a-f0-9]{32}$"))return null; + var section=config.GetSection("Payments:Hotels:"+hotel);if(!section.Exists())return null; + var p=section.Get()!; + if(p.BaseUrl is not ("https://sandbox.nmi.com" or "https://secure.nmi.com") || !Regex.IsMatch(p.MerchantAccount,"^[A-Za-z0-9_-]{1,100}$") || string.IsNullOrWhiteSpace(p.SecurityKey)||p.SecurityKey.Length>4000||p.SecurityKey.Any(char.IsControl))throw new PaymentInvalid("The administrator must complete a valid NMI merchant configuration."); + return p; + } +} +public sealed class NmiInvoices(HttpClient http) +{ + public static string Text(JsonElement e,string name)=>e.ValueKind==JsonValueKind.Object&&e.TryGetProperty(name,out var v)&&v.ValueKind is JsonValueKind.String or JsonValueKind.Number?v.ToString():""; + public static string Id(JsonElement e){var id=Text(e,"id");if(!Regex.IsMatch(id,"^[1-9][0-9]{0,19}$"))throw new PaymentInvalid("NMI returned an invalid invoice identity.");return id;} + public static object Payload(PaymentRequest p)=>new {amount=p.Amount,currency=p.Currency,payment_terms="upon_receipt",payment_methods_allowed=new[]{"cc"},billing_address=new{email=p.Email},order_details=new{order_id=p.Id,order_description=p.Description}}; + async Task Send(NmiProfile profile,HttpMethod method,string path,object? body,CancellationToken ct) + { + using var request=new HttpRequestMessage(method,profile.BaseUrl+"/api/v5/"+path); + request.Headers.Add("Authorization",profile.SecurityKey); + request.Headers.Accept.ParseAdd("application/json"); + if(body!=null){request.Content=new StringContent(JsonSerializer.Serialize(body),Encoding.UTF8);request.Content.Headers.ContentType=new("application/json");} + using var response=await http.SendAsync(request,ct);response.EnsureSuccessStatusCode(); + using var json=JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));return json.RootElement.Clone(); + } + public Task Create(NmiProfile profile,PaymentRequest p,CancellationToken ct)=>Send(profile,HttpMethod.Post,"invoices",Payload(p),ct); + public Task Get(NmiProfile profile,string id,CancellationToken ct) + { + if(!Regex.IsMatch(id,"^[1-9][0-9]{0,19}$"))throw new PaymentInvalid("Invalid invoice ID."); + return Send(profile,HttpMethod.Get,"invoices/"+id,null,ct); + } + public async Task Find(NmiProfile profile,PaymentRequest p,CancellationToken ct) + { + var matches=new List();var cursor="";var seen=new HashSet(); + for(int page=0;page<10;page++) + { + var result=await Send(profile,HttpMethod.Get,"invoices?per_page=100&date_from="+p.CreatedAt.AddDays(-1).ToString("yyyy-MM-dd",CultureInfo.InvariantCulture)+(cursor.Length>0?"&cursor="+cursor:""),null,ct); + if(!result.TryGetProperty("invoices",out var rows)||rows.ValueKind!=JsonValueKind.Array)throw new PaymentInvalid("NMI invoice search could not be verified."); + foreach(var row in rows.EnumerateArray())if(row.TryGetProperty("order_details",out var order)&&Text(order,"order_id")==p.Id)matches.Add(row.Clone()); + if(matches.Count>1)throw new PaymentConflict("More than one invoice matches. Reconcile in the NMI portal; do not create another."); + if(!result.TryGetProperty("next_cursor",out var next))throw new PaymentInvalid("NMI pagination is incomplete."); + if(next.ValueKind==JsonValueKind.Null){if(matches.Count!=1)throw new PaymentConflict("No invoice was confirmed. Keep this request on hold and check NMI; creation will not be repeated.");return matches[0];} + cursor=next.ToString();if(!Regex.IsMatch(cursor,"^[1-9][0-9]{0,19}$")||!seen.Add(cursor))throw new PaymentInvalid("NMI pagination could not be verified."); + } + throw new PaymentConflict("The invoice search exceeded its limit. Reconcile in the NMI portal."); + } + public static string Verify(PaymentRequest p,JsonElement e) + { + var id=Id(e); + if(Text(e,"object")!="invoice"||(p.InvoiceId.Length>0&&p.InvoiceId!=id)||!e.TryGetProperty("order_details",out var order)||Text(order,"order_id")!=p.Id||!e.TryGetProperty("billing_address",out var billing)||!string.Equals(Text(billing,"email"),p.Email,StringComparison.OrdinalIgnoreCase)||Text(e,"currency")!=p.Currency||!decimal.TryParse(Text(e,"amount"),NumberStyles.AllowDecimalPoint,CultureInfo.InvariantCulture,out var amount)||amount!=p.Amount)throw new PaymentConflict("Invoice identity, recipient, amount or currency does not match. Reconcile in NMI."); + return Text(e,"status") switch {"open"=>"Open","overdue"=>"Overdue","partially_paid"=>"Partial","paid"=>"Paid","closed"=>"Closed",_=>throw new PaymentConflict("NMI returned an unrecognised invoice status.")}; + } +} +public sealed class PaymentWork(IStore store,NmiInvoices nmi,IConfiguration config) +{ + NmiProfile Profile(string hotel)=>NmiProfile.Read(config,hotel)??throw new PaymentInvalid("Your administrator has not configured NMI for this hotel."); + public async Task Propose(string hotel,string user,PaymentInput input) + { + var profile=Profile(hotel); + if(!Regex.IsMatch(input.Reference??"","^[A-Za-z0-9-]{1,80}$")||!Input.Text(input.Description,1,250)||input.Amount<=0||input.Amount>100000||decimal.Round(input.Amount,2)!=input.Amount||input.Currency is not ("GBP" or "EUR" or "USD"))throw new PaymentInvalid("Use a unique payment reference, a description, and an amount from 0.01 to 100,000 with two decimal places in GBP, EUR or USD."); + if(!MailAddress.TryCreate(input.Email,out var email)||email.Address!=input.Email||input.Email.Length>254||input.Email.Any(char.IsControl))throw new PaymentInvalid("Enter one plain customer email address."); + var p=new PaymentRequest{HotelId=hotel,Reference=input.Reference!.ToUpperInvariant(),Email=email.Address,Description=input.Description.Trim(),Amount=input.Amount,Currency=input.Currency,Binding=profile.Binding,Revision=profile.Revision,ProposedBy=user}; + if(!await store.TryInsertPayment(p))throw new PaymentConflict("This payment reference already exists. Review its history instead of creating another invoice.");return p; + } + async Task Save(PaymentRequest p,string state,string detail) + { + long old=p.Version;p.Version++;p.State=state;p.Detail=detail;p.UpdatedAt=DateTime.UtcNow; + if(!await store.Replace(p.HotelId,p.Id,old,p))throw new PaymentConflict("The payment request changed elsewhere. Refresh its status."); + } + async Task Enabled(string hotel,NmiProfile profile){if(!profile.CreatesEnabled||(await store.Get(hotel,hotel))?.PaymentsEnabled!=true)throw new PaymentInvalid("Payment creation is disabled for this hotel.");} + public async Task Create(PaymentRequest p,long version,string user,bool approved,CancellationToken ct) + { + if(p.Version!=version||p.State!="Review")throw new PaymentConflict("Only a current proposal can be approved once."); + if(!approved)throw new PaymentInvalid("Approve the recipient, amount and NMI customer email before creating an invoice."); + if(p.ExpiresAt Check(PaymentRequest p,long version,CancellationToken ct) + { + if(p.Version!=version||p.State is "Review" or "Cancelled" or "NotCreated"||p.State=="Creating"&&p.UpdatedAt>DateTime.UtcNow.AddMinutes(-5))throw new PaymentConflict("Refresh the request. Interrupted creation can be checked after five minutes."); + var profile=Profile(p.HotelId);if(p.Binding!=profile.Binding)throw new PaymentConflict("Restore the original merchant binding before checking this invoice."); + using var deadline=CancellationTokenSource.CreateLinkedTokenSource(ct);deadline.CancelAfter(TimeSpan.FromSeconds(90)); + try {var result=p.InvoiceId.Length>0?await nmi.Get(profile,p.InvoiceId,deadline.Token):await nmi.Find(profile,p,deadline.Token);await Observe(p,result);} + catch(Exception){await Save(p,"NeedsReview","The invoice could not be verified. Check the merchant portal; no creation, email or payment was repeated.");} + return p; + } + public async Task Cancel(PaymentRequest p,long version) + { + if(p.Version!=version||p.State!="Review")throw new PaymentConflict("Only a proposal that has not started can be cancelled here.");await Save(p,"Cancelled","Proposal cancelled before contacting NMI. The reference remains reserved for audit history.");return p; + } +} diff --git a/src/GuestOps.Api/Program.cs b/src/GuestOps.Api/Program.cs index 426ca82..1f29603 100644 --- a/src/GuestOps.Api/Program.cs +++ b/src/GuestOps.Api/Program.cs @@ -14,6 +14,7 @@ using System.Net; var bootstrap = args.Contains("--bootstrap"); var builder = WebApplication.CreateBuilder(args.Where(arg => arg != "--bootstrap").ToArray()); if (builder.Configuration["Pms:ConfigFile"] is { Length: > 0 } pmsConfigFile) builder.Configuration.AddJsonFile(pmsConfigFile,optional:false,reloadOnChange:false); +if (builder.Configuration["Payments:ConfigFile"] is { Length: > 0 } paymentConfigFile) builder.Configuration.AddJsonFile(paymentConfigFile,optional:false,reloadOnChange:false); builder.Logging.ClearProviders(); builder.Logging.AddConsole(); builder.Logging.AddFilter("Microsoft.AspNetCore.Hosting.Diagnostics", LogLevel.Warning); builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning); @@ -31,6 +32,8 @@ builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(6 builder.Services.AddSingleton(); builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(25)).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }); builder.Services.AddTransient(); +builder.Services.AddHttpClient(c=>c.Timeout=TimeSpan.FromSeconds(25)).ConfigurePrimaryHttpMessageHandler(()=>new HttpClientHandler{AllowAutoRedirect=false}); +builder.Services.AddTransient(); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(o => { o.Cookie.Name = "guestops.session"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Lax; @@ -87,6 +90,8 @@ app.Use(async (ctx, next) => ctx.Response.Headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; if (ctx.Request.Path.StartsWithSegments("/api")) ctx.Response.Headers.CacheControl = "no-store"; try { await next(); } + catch (PaymentInvalid ex) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); } + catch (PaymentConflict ex) { ctx.Response.StatusCode = 409; await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); } catch (PmsInvalid ex) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); } catch (PmsConflict ex) { ctx.Response.StatusCode = 409; await ctx.Response.WriteAsJsonAsync(new { error = ex.Message }); } catch (AntiforgeryValidationException) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync(new { error = "Your session needs refreshing. Reload the page and try again." }); } @@ -124,6 +129,7 @@ app.MapPost("/api/auth/logout", async (HttpContext c, CancellationToken _) => { if (preview) app.MapPost("/api/preview/start", async (HttpContext c, CancellationToken _) => { var user = await Demo.Seed(store); await Session.SignIn(c, user); return Results.Ok(); }).RequireRateLimiting("login"); var api = app.MapGroup("/api").RequireAuthorization(); PmsEndpoints.Map(api,preview); +PaymentEndpoints.Map(api,preview); api.MapGet("/hotel", async (HttpContext c, CancellationToken _) => Results.Ok(await store.Get(Session.Hotel(c), Session.Hotel(c)))); api.MapPut("/hotel", async (SettingsInput input, HttpContext c) => { diff --git a/src/GuestOps.Api/Store.cs b/src/GuestOps.Api/Store.cs index 336bcde..1bdc76e 100644 --- a/src/GuestOps.Api/Store.cs +++ b/src/GuestOps.Api/Store.cs @@ -22,6 +22,7 @@ public interface IStore Task Import(Conversation message); Task> Deliveries(); Task TryInsertPmsChange(PmsChange change); + Task TryInsertPayment(PaymentRequest payment); } public sealed class MongoStore : IStore { @@ -42,6 +43,8 @@ public sealed class MongoStore : IStore } public async Task Initialize() { + await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x=>x.HotelId).Ascending(x=>x.Reference),new(){Unique=true})); + await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x=>x.HotelId).Descending(x=>x.UpdatedAt))); await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x=>x.HotelId).Ascending(x=>x.ReservationId),new CreateIndexOptions{Unique=true,PartialFilterExpression=Builders.Filter.In(x=>x.State,new[]{"Review","Applying","NeedsReview"})})); await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x=>x.HotelId).Descending(x=>x.UpdatedAt))); await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x=>x.FetchedAt),new(){ExpireAfter=TimeSpan.FromDays(1)})); @@ -57,7 +60,7 @@ public sealed class MongoStore : IStore var query = Collection().Find(Scope(hotel)); if (typeof(T) == typeof(Conversation)) query = query.Sort(Builders.Sort.Descending("ReceivedAt")); if (typeof(T) == typeof(Activity)) query = query.Sort(Builders.Sort.Descending("At")); - if (typeof(T) == typeof(PmsChange)) query = query.Sort(Builders.Sort.Descending("UpdatedAt")); + if (typeof(T) == typeof(PmsChange) || typeof(T) == typeof(PaymentRequest)) query = query.Sort(Builders.Sort.Descending("UpdatedAt")); return query.Limit(500).ToListAsync(); } public async Task Get(string hotel, string id) where T : TenantDocument => await Collection().Find(Scope(hotel) & Builders.Filter.Eq(x => x.Id, id)).FirstOrDefaultAsync(); @@ -98,6 +101,11 @@ public sealed class MongoStore : IStore try { await Insert(message); } catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) { /* already durable */ } } + public async Task TryInsertPayment(PaymentRequest payment) + { + try { await Insert(payment);return true; } + catch(MongoWriteException ex) when(ex.WriteError.Category==ServerErrorCategory.DuplicateKey){return false;} + } public async Task TryInsertPmsChange(PmsChange change) { try { await Insert(change);return true; } @@ -108,6 +116,14 @@ public sealed class MongoStore : IStore // Explicit Development-only preview store. Production never falls back to this. public sealed class PreviewStore : IStore { + public Task TryInsertPayment(PaymentRequest payment) + { + lock(gate) + { + if(rows.Where(x=>x.Key.StartsWith("PaymentRequest:")).Select(x=>Clone(x.Value)).Any(x=>x.HotelId==payment.HotelId&&x.Reference==payment.Reference))return Task.FromResult(false); + return Task.FromResult(rows.TryAdd(Key(payment.Id),Json(payment))); + } + } public Task TryInsertPmsChange(PmsChange change) { lock(gate) diff --git a/tests/GuestOps.Tests/PaymentTests.cs b/tests/GuestOps.Tests/PaymentTests.cs new file mode 100644 index 0000000..ff7127e --- /dev/null +++ b/tests/GuestOps.Tests/PaymentTests.cs @@ -0,0 +1,82 @@ +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")}; + } +} diff --git a/tests/GuestOps.Tests/Program.cs b/tests/GuestOps.Tests/Program.cs index 9131c75..fc2f829 100644 --- a/tests/GuestOps.Tests/Program.cs +++ b/tests/GuestOps.Tests/Program.cs @@ -16,7 +16,8 @@ await store.Initialize(); try { await ReplyTests.Run(Check, store); - await PmsTests.Run(Check, store); + await PmsTests.Run(Check, store); + await PaymentTests.Run(Check, store); var a = new Hotel { Name = "Hotel A" }; a.HotelId = a.Id; var b = new Hotel { Name = "Hotel B" }; b.HotelId = b.Id; await store.Insert(a); await store.Insert(b); @@ -86,6 +87,14 @@ try Check("Preview cannot enable PMS updates", (await one.PutAsJsonAsync("/api/pms/controls",new{version=0,enabled=true})).StatusCode==HttpStatusCode.BadRequest); Check("Unknown PMS snapshot cannot be proposed", (await two.PostAsJsonAsync("/api/pms/changes",new{snapshotId="foreign",kind="AddNote",arrival="",departure="",note="test"})).StatusCode==HttpStatusCode.NotFound); Check("Unknown PMS operation cannot be applied", (await two.PostAsJsonAsync("/api/pms/changes/foreign/apply",new{version=0,availabilityAndPriceChecked=true})).StatusCode==HttpStatusCode.NotFound); + var paymentStatus=await Read(one,"/api/payments/status"); + Check("Preview payment status is disabled and contains no secret", !paymentStatus.GetProperty("configured").GetBoolean()&&!paymentStatus.GetRawText().Contains("securityKey")); + Check("Preview cannot enable invoice creation",(await one.PutAsJsonAsync("/api/payments/controls",new{version=0,enabled=true})).StatusCode==HttpStatusCode.BadRequest); + Check("Preview cannot prepare a real payment",(await one.PostAsJsonAsync("/api/payments/requests",new{reference="TEST",email="guest@example.invalid",description="Deposit",amount=80,currency="GBP"})).StatusCode==HttpStatusCode.BadRequest); + var payments=await Read(one,"/api/payments/requests");var paymentId=payments[0].GetProperty("id").GetString(); + Check("Foreign payment cannot be approved",(await two.PostAsJsonAsync($"/api/payments/requests/{paymentId}/create",new{version=0,emailAndAmountApproved=true})).StatusCode==HttpStatusCode.NotFound); + Check("Foreign payment cannot be reconciled",(await two.PostAsJsonAsync($"/api/payments/requests/{paymentId}/check",new{version=0})).StatusCode==HttpStatusCode.NotFound); + Check("Preview sample invoice cannot be created",(await one.PostAsJsonAsync($"/api/payments/requests/{paymentId}/create",new{version=0,emailAndAmountApproved=true})).StatusCode==HttpStatusCode.BadRequest); var cookie=(await one.GetAsync("/api/session")).Headers; Check("Session response is not cacheable", cookie.CacheControl?.NoStore==true); await one.PostAsJsonAsync("/api/auth/logout",new {}); @@ -98,3 +107,5 @@ finally // This suite owns only a fresh, randomly named database under a fixed test prefix. if (uri != null && dbName.StartsWith("guestops_test_")) await new MongoClient(uri).DropDatabaseAsync(dbName); } + + diff --git a/tests/production_smoke.py b/tests/production_smoke.py index 1c30c3d..ef18ad3 100644 --- a/tests/production_smoke.py +++ b/tests/production_smoke.py @@ -43,6 +43,11 @@ request("/api/auth/login", "POST", {"email": os.environ["BOOTSTRAP_EMAIL"], "pas session = request("/api/session") assert session["user"]["role"] == "Owner" csrf = session["csrfToken"] +payment_status = request("/api/payments/status") +assert payment_status["configured"] is False and payment_status["createsConfigured"] is False +assert "securityKey" not in payment_status +request("/api/payments/controls", "PUT", {"version": 0, "enabled": True}, expected=400) +assert request("/api/payments/requests") == [] hotel = request("/api/hotel") if "--read" in sys.argv: assert hotel["signature"] == "Persisted across container restart" diff --git a/web/src/PaymentsPage.tsx b/web/src/PaymentsPage.tsx new file mode 100644 index 0000000..148c91d --- /dev/null +++ b/web/src/PaymentsPage.tsx @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; +import { api, type Hotel } from './api'; +type Payment={id:string;reference:string;email:string;description:string;amount:number;currency:string;state:string;detail:string;invoiceId:string;version:number;expiresAt:string;updatedAt:string;checkedAt:string|null}; +type Connection={configured:boolean;createsConfigured:boolean;sandbox:boolean;preview:boolean}; +type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise)=>Promise;onHotel:(h:Hotel)=>void}; +export function PaymentsPage({hotel,owner,busy,run,onHotel}:Props){ + const [connection,setConnection]=useState(null),[items,setItems]=useState([]),[selected,setSelected]=useState(null),[approved,setApproved]=useState(false); + const [reference,setReference]=useState(''),[email,setEmail]=useState(''),[description,setDescription]=useState(''),[amount,setAmount]=useState(''),[currency,setCurrency]=useState('GBP'); + async function refresh(){const [c,p]=await Promise.all([api('/payments/status'),api('/payments/requests')]);setConnection(c);setItems(p);} + useEffect(()=>{run(refresh);},[hotel.id]); + const current=items.find(p=>p.id===selected); + function update(p:Payment){setItems(old=>[p,...old.filter(x=>x.id!==p.id)]);setSelected(p.id);setApproved(false);} + async function propose(e:React.FormEvent){e.preventDefault();await run(async()=>update(await api('/payments/requests','POST',{reference,email,description,amount:Number(amount),currency})));} + async function action(name:string){if(!current)return;await run(async()=>{ + if(name==='create'&&!window.confirm(`Create an NMI invoice for ${current.currency} ${current.amount.toFixed(2)} to ${current.email}?\n\nReference: ${current.reference}\n${current.description}\n\nNMI may email the customer a hosted payment link. This action cannot be repeated from GuestOps.`))return; + update(await api(`/payments/requests/${current.id}/${name}`,'POST',{version:current.version,emailAndAmountApproved:approved})); + });} + return
A clear request, a clear record

Payments

Prepare a hosted invoice, review it, and check its status with NMI.

+

Payment connection

{connection?.preview?'Sample workspace: no invoices, emails or payments can be created.':connection?.configured?`NMI ${connection.sandbox?'sandbox':'production'} configuration is available.`:'Your administrator needs to configure this hotel’s NMI merchant account.'}

GuestOps hotel ID: {hotel.id}

Card details are entered on NMI’s hosted page. GuestOps never asks for a card number.

+

Prepare a payment request

Preparation saves a proposal only. Verify the agreed amount and booking terms separately; this does not reserve a room.

+

Payment history

{items.length===0&&

No payment requests yet.

}
{items.map(p=>)}
+ {current&&

{current.state==='Review'?'Review this payment request':'Payment request status'}

Reference
{current.reference}
Customer
{current.email}
Amount
{current.currency} {current.amount.toFixed(2)}
Description
{current.description}
NMI invoice
{current.invoiceId||'Not confirmed'}
Last verified
{current.checkedAt?new Date(current.checkedAt).toLocaleString():'Not yet verified'}

{current.state==='Paid'?'Paid · reported by NMI':current.state} — {current.detail}

+ {current.state==='Review'?<>
Creating the invoice may email this customer a hosted payment link through NMI. GuestOps records the invoice and its status.

Approval expires after fifteen minutes. A payment reference cannot be reused.

:!['Cancelled','NotCreated'].includes(current.state)&&<>

This only reads invoice status. An interrupted creation can be checked after five minutes. Uncertain invoices are never automatically recreated.

} +

A paid invoice is not confirmation of bank settlement or a hotel booking. Refunds, invoice closure and disputes are handled in the merchant portal.

+
} +
; +} diff --git a/web/src/api.ts b/web/src/api.ts index 936c57f..eff596e 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,6 +1,6 @@ export type User = { id: string; name: string; role: string; hotelId: string }; export type Session = { preview: boolean; csrfToken: string; user: User | null }; -export type Hotel = { id: string; name: string; timezone: string; signature: string; replyMode: string; version: number; aiDraftsEnabled: boolean; staffSendingEnabled: boolean; pmsUpdatesEnabled: boolean }; +export type Hotel = { id: string; name: string; timezone: string; signature: string; replyMode: string; version: number; aiDraftsEnabled: boolean; staffSendingEnabled: boolean; paymentsEnabled: boolean; pmsUpdatesEnabled: boolean }; export type Conversation = { id: string; from: string; subject: string; body: string; receivedAt: string; status: string; draft: string; category: string; note: string; providerThreadId: string; version: number; mailboxId: string; replyAddress: string; draftSources: string[]; draftReviewNote: string; delivery: { state: string; recipient: string; body: string; detail: string; messageId: string; providerId: string } | null }; export type Knowledge = { id: string; title: string; category: string; answer: string; keywords: string; approved: boolean; version: number }; export type Activity = { id: string; at: string; userName: string; action: string }; diff --git a/web/src/main.tsx b/web/src/main.tsx index 7ef117a..859a42b 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; -import { Inbox, BookOpen, Settings, Activity as ActivityIcon, Search, ArrowUpRight, ChevronDown, Check, CheckCheck, Clock3, FileText, LogOut, RefreshCw, ArrowLeft, Plus, X, Mail, ShieldCheck, Save, CircleHelp, Building2, ChevronRight } from 'lucide-react'; +import { Inbox, BookOpen, Settings, Activity as ActivityIcon, Search, ArrowUpRight, ChevronDown, Check, CheckCheck, Clock3, FileText, LogOut, RefreshCw, ArrowLeft, Plus, X, Mail, ShieldCheck, Save, CircleHelp, Banknote, Building2, ChevronRight } from 'lucide-react'; import { api, session, type Session, type Hotel, type Conversation, type Knowledge, type Activity, type Mailboxes } from './api'; import './style.css'; +import { PaymentsPage } from './PaymentsPage'; import { PmsPage } from './PmsPage'; import { ReplyActions, ReplyControls } from './ReplyActions'; @@ -37,13 +38,13 @@ function App() { {e.preventDefault();go('/inbox');}}>gguestops.
{hotel?.name||'Your hotel'}Hotel workspace
WORKSPACE
- +
You're in control

Replies stay as drafts until your team reviews them.

{initials(auth.user.name)}
{auth.user.name}{auth.user.role==='Owner'?'Hotel owner':'Team member'}
-
Workspace{page==='/inbox'?'Inbox':page==='/knowledge'?'Hotel knowledge':page==='/activity'?'Activity':page==='/reservations'?'Reservations':'Settings'}
{auth.preview&&Preview · sample data}{hotel?.staffSendingEnabled?'Staff-approved sending':'Draft-only mode'}
+
Workspace{page==='/inbox'?'Inbox':page==='/knowledge'?'Hotel knowledge':page==='/activity'?'Activity':page==='/reservations'?'Reservations':page==='/payments'?'Payments':'Settings'}
{auth.preview&&Preview · sample data}{hotel?.staffSendingEnabled?'Staff-approved sending':'Draft-only mode'}
{errorBox}{notice&&
{notice}
} - {!loaded?

Loading your hotel…

:page==='/inbox'?setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/reservations'?:page==='/knowledge'?setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?
{activity.length?activity.map(a=>
{a.action}

{a.userName}

):}
:{setHotel(h);setNotice('Hotel settings saved.');}}/>} + {!loaded?

Loading your hotel…

:page==='/inbox'?setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/payments'?:page==='/reservations'?:page==='/knowledge'?setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?
{activity.length?activity.map(a=>
{a.action}

{a.userName}

):}
:{setHotel(h);setNotice('Hotel settings saved.');}}/>}
; }