Add reviewed NMI hosted invoices and read-only payment reconciliation
This commit is contained in:
parent
5bd71175c0
commit
ad57a8b1bd
@ -9,3 +9,5 @@
|
||||
tests
|
||||
*.tar*
|
||||
**/pms.local.json
|
||||
|
||||
**/payments.local.json
|
||||
|
||||
@ -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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -13,3 +13,5 @@
|
||||
*.tar.gz
|
||||
.DS_Store
|
||||
**/pms.local.json
|
||||
|
||||
**/payments.local.json
|
||||
|
||||
@ -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).
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
1
deploy/payments.example.json
Normal file
1
deploy/payments.example.json
Normal file
@ -0,0 +1 @@
|
||||
{ "Payments": { "Hotels": {} } }
|
||||
@ -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.
|
||||
|
||||
|
||||
|
||||
51
docs/payments.md
Normal file
51
docs/payments.md
Normal file
@ -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.
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
32
src/GuestOps.Api/PaymentEndpoints.cs
Normal file
32
src/GuestOps.Api/PaymentEndpoints.cs
Normal file
@ -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<PaymentRequest>(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<PaymentRequest>(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<PaymentRequest>(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<PaymentRequest>(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<Hotel>(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");
|
||||
}
|
||||
}
|
||||
147
src/GuestOps.Api/Payments.cs
Normal file
147
src/GuestOps.Api/Payments.cs
Normal file
@ -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<NmiProfile>()!;
|
||||
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<JsonElement> 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<JsonElement> Create(NmiProfile profile,PaymentRequest p,CancellationToken ct)=>Send(profile,HttpMethod.Post,"invoices",Payload(p),ct);
|
||||
public Task<JsonElement> 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<JsonElement> Find(NmiProfile profile,PaymentRequest p,CancellationToken ct)
|
||||
{
|
||||
var matches=new List<JsonElement>();var cursor="";var seen=new HashSet<string>();
|
||||
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<PaymentRequest> 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,hotel))?.PaymentsEnabled!=true)throw new PaymentInvalid("Payment creation is disabled for this hotel.");}
|
||||
public async Task<PaymentRequest> 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<DateTime.UtcNow)throw new PaymentConflict("The proposal expired. Cancel it and prepare a new payment reference.");
|
||||
var profile=Profile(p.HotelId);if(profile.Revision!=p.Revision)throw new PaymentConflict("Merchant configuration changed. Prepare a new reviewed request.");await Enabled(p.HotelId,profile);
|
||||
p.ApprovedBy=user;await Save(p,"Creating","Creating the approved NMI invoice. NMI may email the customer.");
|
||||
using var deadline=CancellationTokenSource.CreateLinkedTokenSource(ct);deadline.CancelAfter(TimeSpan.FromSeconds(90));bool submitted=false;
|
||||
try
|
||||
{
|
||||
await Enabled(p.HotelId,profile);deadline.Token.ThrowIfCancellationRequested();submitted=true;
|
||||
var result=await nmi.Create(profile,p,deadline.Token);p.InvoiceId=NmiInvoices.Id(result);
|
||||
await Save(p,"Creating","Invoice identity received; checking the recorded details.");
|
||||
var read=await nmi.Get(profile,p.InvoiceId,deadline.Token);await Observe(p,read);
|
||||
}
|
||||
catch(Exception){await Save(p,submitted?"NeedsReview":"NotCreated",submitted?"Creation outcome needs verification. NMI may have created or emailed the invoice. Do not repeat it.":"No invoice creation was submitted.");}
|
||||
return p;
|
||||
}
|
||||
async Task Observe(PaymentRequest p,JsonElement read)
|
||||
{
|
||||
var state=NmiInvoices.Verify(p,read);p.InvoiceId=NmiInvoices.Id(read);p.CheckedAt=DateTime.UtcNow;
|
||||
await Save(p,state,state=="Paid"?"NMI reports this invoice paid. This is not bank settlement confirmation and does not create or update a PMS booking.":"Invoice details and current status were read from NMI. No payment or email was initiated by this check.");
|
||||
}
|
||||
public async Task<PaymentRequest> 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<PaymentRequest> 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;
|
||||
}
|
||||
}
|
||||
@ -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<AiDrafts>(c => c.Timeout = TimeSpan.FromSeconds(6
|
||||
builder.Services.AddSingleton<OhipTokens>();
|
||||
builder.Services.AddHttpClient<OhipClient>(c => c.Timeout = TimeSpan.FromSeconds(25)).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
|
||||
builder.Services.AddTransient<PmsWork>();
|
||||
builder.Services.AddHttpClient<NmiInvoices>(c=>c.Timeout=TimeSpan.FromSeconds(25)).ConfigurePrimaryHttpMessageHandler(()=>new HttpClientHandler{AllowAutoRedirect=false});
|
||||
builder.Services.AddTransient<PaymentWork>();
|
||||
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<Hotel>(Session.Hotel(c), Session.Hotel(c))));
|
||||
api.MapPut("/hotel", async (SettingsInput input, HttpContext c) =>
|
||||
{
|
||||
|
||||
@ -22,6 +22,7 @@ public interface IStore
|
||||
Task Import(Conversation message);
|
||||
Task<List<Conversation>> Deliveries();
|
||||
Task<bool> TryInsertPmsChange(PmsChange change);
|
||||
Task<bool> TryInsertPayment(PaymentRequest payment);
|
||||
}
|
||||
public sealed class MongoStore : IStore
|
||||
{
|
||||
@ -42,6 +43,8 @@ public sealed class MongoStore : IStore
|
||||
}
|
||||
public async Task Initialize()
|
||||
{
|
||||
await Collection<PaymentRequest>().Indexes.CreateOneAsync(new CreateIndexModel<PaymentRequest>(Builders<PaymentRequest>.IndexKeys.Ascending(x=>x.HotelId).Ascending(x=>x.Reference),new(){Unique=true}));
|
||||
await Collection<PaymentRequest>().Indexes.CreateOneAsync(new CreateIndexModel<PaymentRequest>(Builders<PaymentRequest>.IndexKeys.Ascending(x=>x.HotelId).Descending(x=>x.UpdatedAt)));
|
||||
await Collection<PmsChange>().Indexes.CreateOneAsync(new CreateIndexModel<PmsChange>(Builders<PmsChange>.IndexKeys.Ascending(x=>x.HotelId).Ascending(x=>x.ReservationId),new CreateIndexOptions<PmsChange>{Unique=true,PartialFilterExpression=Builders<PmsChange>.Filter.In(x=>x.State,new[]{"Review","Applying","NeedsReview"})}));
|
||||
await Collection<PmsChange>().Indexes.CreateOneAsync(new CreateIndexModel<PmsChange>(Builders<PmsChange>.IndexKeys.Ascending(x=>x.HotelId).Descending(x=>x.UpdatedAt)));
|
||||
await Collection<PmsSnapshot>().Indexes.CreateOneAsync(new CreateIndexModel<PmsSnapshot>(Builders<PmsSnapshot>.IndexKeys.Ascending(x=>x.FetchedAt),new(){ExpireAfter=TimeSpan.FromDays(1)}));
|
||||
@ -57,7 +60,7 @@ public sealed class MongoStore : IStore
|
||||
var query = Collection<T>().Find(Scope<T>(hotel));
|
||||
if (typeof(T) == typeof(Conversation)) query = query.Sort(Builders<T>.Sort.Descending("ReceivedAt"));
|
||||
if (typeof(T) == typeof(Activity)) query = query.Sort(Builders<T>.Sort.Descending("At"));
|
||||
if (typeof(T) == typeof(PmsChange)) query = query.Sort(Builders<T>.Sort.Descending("UpdatedAt"));
|
||||
if (typeof(T) == typeof(PmsChange) || typeof(T) == typeof(PaymentRequest)) query = query.Sort(Builders<T>.Sort.Descending("UpdatedAt"));
|
||||
return query.Limit(500).ToListAsync();
|
||||
}
|
||||
public async Task<T?> Get<T>(string hotel, string id) where T : TenantDocument => await Collection<T>().Find(Scope<T>(hotel) & Builders<T>.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<bool> TryInsertPayment(PaymentRequest payment)
|
||||
{
|
||||
try { await Insert(payment);return true; }
|
||||
catch(MongoWriteException ex) when(ex.WriteError.Category==ServerErrorCategory.DuplicateKey){return false;}
|
||||
}
|
||||
public async Task<bool> 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<bool> TryInsertPayment(PaymentRequest payment)
|
||||
{
|
||||
lock(gate)
|
||||
{
|
||||
if(rows.Where(x=>x.Key.StartsWith("PaymentRequest:")).Select(x=>Clone<PaymentRequest>(x.Value)).Any(x=>x.HotelId==payment.HotelId&&x.Reference==payment.Reference))return Task.FromResult(false);
|
||||
return Task.FromResult(rows.TryAdd(Key<PaymentRequest>(payment.Id),Json(payment)));
|
||||
}
|
||||
}
|
||||
public Task<bool> TryInsertPmsChange(PmsChange change)
|
||||
{
|
||||
lock(gate)
|
||||
|
||||
82
tests/GuestOps.Tests/PaymentTests.cs
Normal file
82
tests/GuestOps.Tests/PaymentTests.cs
Normal file
@ -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<string,bool> 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<string,string?>{{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<PaymentRequest> 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<PaymentRequest>(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<PaymentRequest>("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 <guest@example.invalid>","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<PaymentRequest>(hotel.Id,p.Id))!;var two=(await store.Get<PaymentRequest>(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<PaymentRequest>(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<decimal>()==80.25m&&handler.Last["currency"]!.GetValue<string>()=="GBP"&&handler.Last["order_details"]!["order_id"]!.GetValue<string>()==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<bool> Blocked(Func<Task> 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<string> Paths=[];
|
||||
protected override async Task<HttpResponseMessage> 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")};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
27
web/src/PaymentsPage.tsx
Normal file
27
web/src/PaymentsPage.tsx
Normal file
@ -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<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
|
||||
export function PaymentsPage({hotel,owner,busy,run,onHotel}:Props){
|
||||
const [connection,setConnection]=useState<Connection|null>(null),[items,setItems]=useState<Payment[]>([]),[selected,setSelected]=useState<string|null>(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<Connection>('/payments/status'),api<Payment[]>('/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<Payment>('/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<Payment>(`/payments/requests/${current.id}/${name}`,'POST',{version:current.version,emailAndAmountApproved:approved}));
|
||||
});}
|
||||
return <div className="page"><div className="heading-row"><div className="page-heading"><span className="eyebrow">A clear request, a clear record</span><h1>Payments</h1><p>Prepare a hosted invoice, review it, and check its status with NMI.</p></div><button className="button secondary" disabled={busy} onClick={()=>run(refresh)}>Refresh history</button></div>
|
||||
<section className="settings-card"><h2>Payment connection</h2><p>{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.'}</p><p className="small muted">GuestOps hotel ID: {hotel.id}</p><label className="checkbox-label"><input type="checkbox" checked={hotel.paymentsEnabled||false} disabled={busy||!owner||(!connection?.createsConfigured&&!hotel.paymentsEnabled)} onChange={e=>{const enabled=e.target.checked;run(async()=>{if(enabled&&!window.confirm('Enable reviewed NMI invoice creation after sandbox acceptance? Creating an invoice may email the customer.'))return;onHotel(await api<Hotel>('/payments/controls','PUT',{version:hotel.version,enabled}));});}}/>Allow owner-approved payment invoices</label><p className="small muted">Card details are entered on NMI’s hosted page. GuestOps never asks for a card number.</p></section>
|
||||
<div className="pms-columns"><section className="settings-card"><h2>Prepare a payment request</h2><form onSubmit={propose}><fieldset disabled={busy||!owner||!connection?.configured}><label>Unique payment reference<input value={reference} onChange={e=>setReference(e.target.value)} pattern="[A-Za-z0-9-]+" maxLength={80} required placeholder="WH-2481-DEPOSIT"/></label><label>Customer email<input type="email" value={email} onChange={e=>setEmail(e.target.value)} maxLength={254} required/></label><label>Description<input value={description} onChange={e=>setDescription(e.target.value)} maxLength={250} required placeholder="Deposit for reservation WH-2481"/></label><div className="form-grid"><label>Amount<input type="number" min="0.01" max="100000" step="0.01" required value={amount} onChange={e=>setAmount(e.target.value)}/></label><label>Currency<select value={currency} onChange={e=>setCurrency(e.target.value)}><option>GBP</option><option>EUR</option><option>USD</option></select></label></div><button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparation saves a proposal only. Verify the agreed amount and booking terms separately; this does not reserve a room.</p></section>
|
||||
<section className="settings-card"><h2>Payment history</h2>{items.length===0&&<p>No payment requests yet.</p>}<div className="pms-history">{items.map(p=><button key={p.id} className={'pms-history-item '+(selected===p.id?'selected':'')} onClick={()=>{setSelected(p.id);setApproved(false);}}><strong>{p.reference} · {p.currency} {p.amount.toFixed(2)}</strong><span>{p.state==='NeedsReview'?'Needs verification':p.state==='Paid'?'Paid · reported by NMI':p.state} · {p.email}</span></button>)}</div></section></div>
|
||||
{current&&<section className="settings-card" aria-label="Payment request review"><h2>{current.state==='Review'?'Review this payment request':'Payment request status'}</h2><dl className="pms-reservation"><div><dt>Reference</dt><dd>{current.reference}</dd></div><div><dt>Customer</dt><dd>{current.email}</dd></div><div><dt>Amount</dt><dd>{current.currency} {current.amount.toFixed(2)}</dd></div><div><dt>Description</dt><dd>{current.description}</dd></div><div><dt>NMI invoice</dt><dd>{current.invoiceId||'Not confirmed'}</dd></div><div><dt>Last verified</dt><dd>{current.checkedAt?new Date(current.checkedAt).toLocaleString():'Not yet verified'}</dd></div></dl><p role="status"><strong>{current.state==='Paid'?'Paid · reported by NMI':current.state}</strong> — {current.detail}</p>
|
||||
{current.state==='Review'?<><div className="staff-note">Creating the invoice may email this customer a hosted payment link through NMI. GuestOps records the invoice and its status.</div><label className="checkbox-label"><input type="checkbox" checked={approved} onChange={e=>setApproved(e.target.checked)}/>I checked the recipient, amount and currency, and approve NMI emailing this payment request.</label><div className="form-actions"><button className="button secondary" disabled={busy||!owner||connection?.preview} onClick={()=>action('cancel')}>Cancel proposal</button><button className="button primary" disabled={busy||!owner||connection?.preview||!hotel.paymentsEnabled||!connection?.createsConfigured||!approved||Date.now()>new Date(current.expiresAt).getTime()} onClick={()=>action('create')}>Approve and create invoice</button></div><p className="small muted">Approval expires after fifteen minutes. A payment reference cannot be reused.</p></>:!['Cancelled','NotCreated'].includes(current.state)&&<><button className="button secondary" disabled={busy||!owner||connection?.preview||(current.state==='Creating'&&Date.now()-new Date(current.updatedAt).getTime()<300000)} onClick={()=>action('check')}>Verify with NMI</button><p className="small muted">This only reads invoice status. An interrupted creation can be checked after five minutes. Uncertain invoices are never automatically recreated.</p></>}
|
||||
<p className="small muted">A paid invoice is not confirmation of bank settlement or a hotel booking. Refunds, invoice closure and disputes are handled in the merchant portal.</p>
|
||||
</section>}
|
||||
</div>;
|
||||
}
|
||||
@ -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 };
|
||||
|
||||
@ -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() {
|
||||
<a href="/inbox" aria-label="GuestOps inbox" className="brand" onClick={e=>{e.preventDefault();go('/inbox');}}><span className="brand-mark">g</span><span>guestops<span className="brand-dot">.</span></span></a>
|
||||
<div className="hotel-switch"><span className="hotel-icon"><Building2 size={19}/></span><div><strong>{hotel?.name||'Your hotel'}</strong><small>Hotel workspace</small></div></div>
|
||||
<div className="nav-label">WORKSPACE</div>
|
||||
<nav aria-label="Main navigation">{[{path:'/inbox',label:'Inbox',icon:Inbox},{path:'/reservations',label:'Reservations',icon:Building2},{path:'/knowledge',label:'Hotel knowledge',icon:BookOpen},{path:'/activity',label:'Activity',icon:ActivityIcon},{path:'/settings',label:'Settings',icon:Settings}].map(n=><a key={n.path} href={n.path} aria-label={n.label} title={n.label} className={page===n.path?'nav-item active':'nav-item'} aria-current={page===n.path?'page':undefined} onClick={e=>{e.preventDefault();go(n.path);}}><n.icon size={20}/><span>{n.label}</span>{n.path==='/inbox'&&count>0&&<b>{count}</b>}</a>)}</nav>
|
||||
<nav aria-label="Main navigation">{[{path:'/inbox',label:'Inbox',icon:Inbox},{path:'/reservations',label:'Reservations',icon:Building2},{path:'/payments',label:'Payments',icon:Banknote},{path:'/knowledge',label:'Hotel knowledge',icon:BookOpen},{path:'/activity',label:'Activity',icon:ActivityIcon},{path:'/settings',label:'Settings',icon:Settings}].map(n=><a key={n.path} href={n.path} aria-label={n.label} title={n.label} className={page===n.path?'nav-item active':'nav-item'} aria-current={page===n.path?'page':undefined} onClick={e=>{e.preventDefault();go(n.path);}}><n.icon size={20}/><span>{n.label}</span>{n.path==='/inbox'&&count>0&&<b>{count}</b>}</a>)}</nav>
|
||||
<div className="sidebar-bottom"><div className="mode-card"><ShieldCheck size={20}/><strong>You're in control</strong><p>Replies stay as drafts until your team reviews them.</p></div><div className="profile"><span className="avatar profile-avatar">{initials(auth.user.name)}</span><div><strong>{auth.user.name}</strong><small>{auth.user.role==='Owner'?'Hotel owner':'Team member'}</small></div><button className="icon-button" onClick={logout} disabled={busy} aria-label="Sign out"><LogOut size={18}/></button></div></div>
|
||||
</aside>
|
||||
<main className="main">
|
||||
<header className="topbar"><span><span className="breadcrumb">Workspace</span><ChevronRight size={14}/>{page==='/inbox'?'Inbox':page==='/knowledge'?'Hotel knowledge':page==='/activity'?'Activity':page==='/reservations'?'Reservations':'Settings'}</span><div className="topbar-right">{auth.preview&&<span className="preview-pill">Preview · sample data</span>}<span className="draft-mode"><span/>{hotel?.staffSendingEnabled?'Staff-approved sending':'Draft-only mode'}</span><button className="icon-button" aria-label="Refresh workspace" disabled={busy} onClick={()=>run(refresh)}><RefreshCw size={17}/></button></div></header>
|
||||
<header className="topbar"><span><span className="breadcrumb">Workspace</span><ChevronRight size={14}/>{page==='/inbox'?'Inbox':page==='/knowledge'?'Hotel knowledge':page==='/activity'?'Activity':page==='/reservations'?'Reservations':page==='/payments'?'Payments':'Settings'}</span><div className="topbar-right">{auth.preview&&<span className="preview-pill">Preview · sample data</span>}<span className="draft-mode"><span/>{hotel?.staffSendingEnabled?'Staff-approved sending':'Draft-only mode'}</span><button className="icon-button" aria-label="Refresh workspace" disabled={busy} onClick={()=>run(refresh)}><RefreshCw size={17}/></button></div></header>
|
||||
{errorBox}{notice&&<div className="toast" role="status"><Check size={17}/>{notice}</div>}
|
||||
{!loaded?<div className="loading"><p>Loading your hotel…</p></div>:page==='/inbox'?<InboxPage hotel={hotel!} mailboxes={mailboxes} conversations={conversations} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/reservations'?<PmsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/knowledge'?<KnowledgePage items={knowledge} canEdit={auth.user.role==='Owner'} busy={busy} run={run} onUpdate={item=>setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?<div className="page"><PageHeading eyebrow="A clear record" title="Workspace activity" text="Changes made by your team, in one place."/><div className="activity-list">{activity.length?activity.map(a=><div className="activity-row" key={a.id}><span className="activity-icon"><Check size={18}/></span><div><strong>{a.action}</strong><p>{a.userName}</p></div><time>{date(a.at)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={auth.user.role==='Owner'} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>}
|
||||
{!loaded?<div className="loading"><p>Loading your hotel…</p></div>:page==='/inbox'?<InboxPage hotel={hotel!} mailboxes={mailboxes} conversations={conversations} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/payments'?<PaymentsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/reservations'?<PmsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/knowledge'?<KnowledgePage items={knowledge} canEdit={auth.user.role==='Owner'} busy={busy} run={run} onUpdate={item=>setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?<div className="page"><PageHeading eyebrow="A clear record" title="Workspace activity" text="Changes made by your team, in one place."/><div className="activity-list">{activity.length?activity.map(a=><div className="activity-row" key={a.id}><span className="activity-icon"><Check size={18}/></span><div><strong>{a.action}</strong><p>{a.userName}</p></div><time>{date(a.at)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={auth.user.role==='Owner'} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user