From 2ba208f27eed00cdfd4213b20a80aa94e2041fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ren=C3=A9=20White=20Enciso?= Date: Tue, 31 Mar 2026 16:33:40 -0600 Subject: [PATCH] feat(pos-transactions-bff): add pos workflow endpoints --- Pos.Transactions.Bff.slnx | 3 + docs/api/pos-transaction-workflows.md | 29 +++++ docs/roadmap/feature-epics.md | 1 + docs/runbooks/containerization.md | 5 +- docs/security/auth-enforcement.md | 2 + .../DefaultPosTransactionsServiceClient.cs | 17 --- .../Adapters/IPosTransactionsServiceClient.cs | 4 +- .../OperationsPosTransactionsServiceClient.cs | 118 ++++++++++++++++++ .../GetPosTransactionDetailHandler.cs | 13 ++ .../GetPosTransactionSummaryHandler.cs | 2 +- .../Handlers/GetRecentPosPaymentsHandler.cs | 13 ++ .../IGetPosTransactionDetailHandler.cs | 9 ++ .../Handlers/IGetRecentPosPaymentsHandler.cs | 9 ++ .../Contracts/PosPaymentActivityContract.cs | 9 ++ .../GetPosTransactionDetailRequest.cs | 3 + .../Responses/CapturePosPaymentResponse.cs | 4 +- .../GetPosTransactionDetailResponse.cs | 10 ++ .../GetPosTransactionSummaryResponse.cs | 9 +- .../Responses/GetRecentPosPaymentsResponse.cs | 10 ++ src/Pos.Transactions.Bff.Rest/Program.cs | 46 ++++++- ...ationsPosTransactionsServiceClientTests.cs | 110 ++++++++++++++++ ...nsactions.Bff.Application.UnitTests.csproj | 19 +++ 22 files changed, 421 insertions(+), 24 deletions(-) create mode 100644 docs/api/pos-transaction-workflows.md delete mode 100644 src/Pos.Transactions.Bff.Application/Adapters/DefaultPosTransactionsServiceClient.cs create mode 100644 src/Pos.Transactions.Bff.Application/Adapters/OperationsPosTransactionsServiceClient.cs create mode 100644 src/Pos.Transactions.Bff.Application/Handlers/GetPosTransactionDetailHandler.cs create mode 100644 src/Pos.Transactions.Bff.Application/Handlers/GetRecentPosPaymentsHandler.cs create mode 100644 src/Pos.Transactions.Bff.Application/Handlers/IGetPosTransactionDetailHandler.cs create mode 100644 src/Pos.Transactions.Bff.Application/Handlers/IGetRecentPosPaymentsHandler.cs create mode 100644 src/Pos.Transactions.Bff.Contracts/Contracts/PosPaymentActivityContract.cs create mode 100644 src/Pos.Transactions.Bff.Contracts/Requests/GetPosTransactionDetailRequest.cs create mode 100644 src/Pos.Transactions.Bff.Contracts/Responses/GetPosTransactionDetailResponse.cs create mode 100644 src/Pos.Transactions.Bff.Contracts/Responses/GetRecentPosPaymentsResponse.cs create mode 100644 tests/Pos.Transactions.Bff.Application.UnitTests/OperationsPosTransactionsServiceClientTests.cs create mode 100644 tests/Pos.Transactions.Bff.Application.UnitTests/Pos.Transactions.Bff.Application.UnitTests.csproj diff --git a/Pos.Transactions.Bff.slnx b/Pos.Transactions.Bff.slnx index 7b2afad..bd9944b 100644 --- a/Pos.Transactions.Bff.slnx +++ b/Pos.Transactions.Bff.slnx @@ -4,4 +4,7 @@ + + + diff --git a/docs/api/pos-transaction-workflows.md b/docs/api/pos-transaction-workflows.md new file mode 100644 index 0000000..00e92b1 --- /dev/null +++ b/docs/api/pos-transaction-workflows.md @@ -0,0 +1,29 @@ +# POS Transaction Workflow API + +## Purpose + +This BFF exposes POS summary, transaction detail, recent payment activity, and payment capture workflows over REST while delegating orchestration to `operations-service`. + +## Endpoints + +- `GET /api/pos/transactions/summary?contextId=` + - Returns POS balance and recent payment summary. +- `GET /api/pos/transactions/recent-payments?contextId=` + - Returns the recent POS payment activity snapshot. +- `GET /api/pos/transactions/{transactionId}?contextId=` + - Returns the current detail projection for a single recent payment activity item. +- `POST /api/pos/transactions/payments` + - Captures a POS payment. + +## Upstream Dependency + +- Base address configuration: `OperationsService:BaseAddress` +- Default runtime target: `http://operations-service:8080` +- Internal upstream routes: + - `GET /internal/operations/pos/summary` + - `POST /internal/operations/pos/payments` + +## Notes + +- Transaction detail is currently derived from the recent payment snapshot returned by `operations-service`. +- Correlation IDs are preserved through Thalos session checks and operations-service calls. diff --git a/docs/roadmap/feature-epics.md b/docs/roadmap/feature-epics.md index 37285b1..d997620 100644 --- a/docs/roadmap/feature-epics.md +++ b/docs/roadmap/feature-epics.md @@ -13,6 +13,7 @@ pos-transactions-bff - Kitchen queue and dispatch optimization hooks. - Operations control-plane policies (flags, service windows, overrides). - POS closeout and settlement summary alignment. +- POS transaction detail and recent payment activity views that stay aligned with operations-service workflows. ## Documentation Contract Any code change in this repository must include docs updates in the same branch. diff --git a/docs/runbooks/containerization.md b/docs/runbooks/containerization.md index fae9bd6..1c93680 100644 --- a/docs/runbooks/containerization.md +++ b/docs/runbooks/containerization.md @@ -22,7 +22,8 @@ docker run --rm -p 8080:8080 --name pos-transactions-bff agilewebs/pos-transacti ## Runtime Notes -- Exposes REST edge endpoints for transaction summary and payment capture. +- Exposes REST edge endpoints for transaction summary, transaction detail, recent payment activity, and payment capture. +- Requires `OperationsService__BaseAddress` to resolve the upstream operations-service runtime. - Requires `ThalosAuth__BaseAddress` to resolve Thalos session introspection endpoint. - Returns standardized auth failures (`401|403|503`) with `x-correlation-id` propagation. @@ -38,5 +39,5 @@ docker run --rm -p 8080:8080 --name pos-transactions-bff agilewebs/pos-transacti - Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml` ## Known Limitations -- Current runtime adapters are still predominantly in-memory for deterministic local/demo behavior. +- Pos-transactions now delegates workflow snapshots to `operations-service`, but the upstream operations adapter still serves deterministic demo data rather than database-backed state. - Demo PostgreSQL seeds validate integration contracts and smoke determinism, but do not yet imply full persistence implementation parity. diff --git a/docs/security/auth-enforcement.md b/docs/security/auth-enforcement.md index 7fb8a6f..6a66fe5 100644 --- a/docs/security/auth-enforcement.md +++ b/docs/security/auth-enforcement.md @@ -7,6 +7,8 @@ This BFF enforces authenticated access on business endpoints using Thalos sessio ## Protected Endpoints - `/api/pos/transactions/summary` +- `/api/pos/transactions/recent-payments` +- `/api/pos/transactions/{transactionId}` - `/api/pos/transactions/payments` ## Anonymous Endpoints diff --git a/src/Pos.Transactions.Bff.Application/Adapters/DefaultPosTransactionsServiceClient.cs b/src/Pos.Transactions.Bff.Application/Adapters/DefaultPosTransactionsServiceClient.cs deleted file mode 100644 index 66440af..0000000 --- a/src/Pos.Transactions.Bff.Application/Adapters/DefaultPosTransactionsServiceClient.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Pos.Transactions.Bff.Contracts.Requests; -using Pos.Transactions.Bff.Contracts.Responses; - -namespace Pos.Transactions.Bff.Application.Adapters; - -public sealed class DefaultPosTransactionsServiceClient : IPosTransactionsServiceClient -{ - public Task FetchAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken) - { - return Task.FromResult(new GetPosTransactionSummaryResponse(request.ContextId, "Default service-backed response.")); - } - - public Task CapturePaymentAsync(CapturePosPaymentRequest request, CancellationToken cancellationToken) - { - return Task.FromResult(new CapturePosPaymentResponse(request.ContextId, request.TransactionId, true, "Payment captured by default adapter.")); - } -} diff --git a/src/Pos.Transactions.Bff.Application/Adapters/IPosTransactionsServiceClient.cs b/src/Pos.Transactions.Bff.Application/Adapters/IPosTransactionsServiceClient.cs index 9168951..67e041b 100644 --- a/src/Pos.Transactions.Bff.Application/Adapters/IPosTransactionsServiceClient.cs +++ b/src/Pos.Transactions.Bff.Application/Adapters/IPosTransactionsServiceClient.cs @@ -5,6 +5,8 @@ namespace Pos.Transactions.Bff.Application.Adapters; public interface IPosTransactionsServiceClient { - Task FetchAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken); + Task FetchSummaryAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken); + Task FetchDetailAsync(GetPosTransactionDetailRequest request, CancellationToken cancellationToken); + Task FetchRecentPaymentsAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken); Task CapturePaymentAsync(CapturePosPaymentRequest request, CancellationToken cancellationToken); } diff --git a/src/Pos.Transactions.Bff.Application/Adapters/OperationsPosTransactionsServiceClient.cs b/src/Pos.Transactions.Bff.Application/Adapters/OperationsPosTransactionsServiceClient.cs new file mode 100644 index 0000000..ea64a25 --- /dev/null +++ b/src/Pos.Transactions.Bff.Application/Adapters/OperationsPosTransactionsServiceClient.cs @@ -0,0 +1,118 @@ +using System.Net.Http.Json; +using Pos.Transactions.Bff.Contracts.Contracts; +using Pos.Transactions.Bff.Contracts.Requests; +using Pos.Transactions.Bff.Contracts.Responses; + +namespace Pos.Transactions.Bff.Application.Adapters; + +public sealed class OperationsPosTransactionsServiceClient(HttpClient httpClient) : IPosTransactionsServiceClient +{ + public async Task FetchSummaryAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken) + { + var payload = await GetSummaryPayloadAsync(request.ContextId, cancellationToken); + + return new GetPosTransactionSummaryResponse( + payload.ContextId, + payload.Summary, + payload.OpenBalance, + payload.Currency, + MapPayments(payload.RecentPayments)); + } + + public async Task FetchDetailAsync(GetPosTransactionDetailRequest request, CancellationToken cancellationToken) + { + var payload = await GetSummaryPayloadAsync(request.ContextId, cancellationToken); + // operations-service currently exposes recent-payment snapshots rather than a dedicated transaction detail endpoint. + var payment = MapPayments(payload.RecentPayments).FirstOrDefault(activity => activity.TransactionId == request.TransactionId); + + return new GetPosTransactionDetailResponse( + payload.ContextId, + payment is null + ? $"Transaction {request.TransactionId} is not visible in the current POS summary." + : $"Transaction {request.TransactionId} is currently {payment.Status}.", + payload.OpenBalance, + payload.Currency, + payment); + } + + public async Task FetchRecentPaymentsAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken) + { + var payload = await GetSummaryPayloadAsync(request.ContextId, cancellationToken); + + return new GetRecentPosPaymentsResponse( + payload.ContextId, + payload.Summary, + payload.OpenBalance, + payload.Currency, + MapPayments(payload.RecentPayments)); + } + + public async Task CapturePaymentAsync(CapturePosPaymentRequest request, CancellationToken cancellationToken) + { + var payload = await CapturePaymentPayloadAsync(request, cancellationToken); + + return new CapturePosPaymentResponse( + payload.ContextId, + payload.TransactionId, + payload.Succeeded, + payload.Summary, + payload.Status, + payload.CapturedAtUtc); + } + + private async Task GetSummaryPayloadAsync(string contextId, CancellationToken cancellationToken) + { + var payload = await httpClient.GetFromJsonAsync( + $"internal/operations/pos/summary?contextId={Uri.EscapeDataString(contextId)}", + cancellationToken); + + return payload ?? throw new InvalidOperationException("Operations service returned an empty POS summary payload."); + } + + private async Task CapturePaymentPayloadAsync( + CapturePosPaymentRequest request, + CancellationToken cancellationToken) + { + using var response = await httpClient.PostAsJsonAsync("internal/operations/pos/payments", request, cancellationToken); + response.EnsureSuccessStatusCode(); + + var payload = await response.Content.ReadFromJsonAsync(cancellationToken); + return payload ?? throw new InvalidOperationException("Operations service returned an empty POS capture payload."); + } + + private static IReadOnlyCollection MapPayments(IReadOnlyCollection payments) + { + return payments + .Select(static payment => new PosPaymentActivityContract( + payment.TransactionId, + payment.PaymentMethod, + payment.Amount, + payment.Currency, + payment.Status, + payment.CapturedAtUtc)) + .ToArray(); + } + + private sealed record GetPosTransactionSummaryPayload( + string ContextId, + string Summary, + decimal OpenBalance, + string Currency, + IReadOnlyCollection RecentPayments); + + private sealed record PosPaymentActivityPayload( + string TransactionId, + string PaymentMethod, + decimal Amount, + string Currency, + string Status, + DateTime CapturedAtUtc); + + private sealed record CapturePosPaymentResponsePayload( + string ContextId, + string TransactionId, + bool Succeeded, + string Summary, + string Status, + DateTime CapturedAtUtc); +} diff --git a/src/Pos.Transactions.Bff.Application/Handlers/GetPosTransactionDetailHandler.cs b/src/Pos.Transactions.Bff.Application/Handlers/GetPosTransactionDetailHandler.cs new file mode 100644 index 0000000..a627412 --- /dev/null +++ b/src/Pos.Transactions.Bff.Application/Handlers/GetPosTransactionDetailHandler.cs @@ -0,0 +1,13 @@ +using Pos.Transactions.Bff.Application.Adapters; +using Pos.Transactions.Bff.Contracts.Requests; +using Pos.Transactions.Bff.Contracts.Responses; + +namespace Pos.Transactions.Bff.Application.Handlers; + +public sealed class GetPosTransactionDetailHandler(IPosTransactionsServiceClient serviceClient) : IGetPosTransactionDetailHandler +{ + public Task HandleAsync(GetPosTransactionDetailRequest request, CancellationToken cancellationToken) + { + return serviceClient.FetchDetailAsync(request, cancellationToken); + } +} diff --git a/src/Pos.Transactions.Bff.Application/Handlers/GetPosTransactionSummaryHandler.cs b/src/Pos.Transactions.Bff.Application/Handlers/GetPosTransactionSummaryHandler.cs index aa99a1c..7149d56 100644 --- a/src/Pos.Transactions.Bff.Application/Handlers/GetPosTransactionSummaryHandler.cs +++ b/src/Pos.Transactions.Bff.Application/Handlers/GetPosTransactionSummaryHandler.cs @@ -8,6 +8,6 @@ public sealed class GetPosTransactionSummaryHandler(IPosTransactionsServiceClien { public Task HandleAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken) { - return serviceClient.FetchAsync(request, cancellationToken); + return serviceClient.FetchSummaryAsync(request, cancellationToken); } } diff --git a/src/Pos.Transactions.Bff.Application/Handlers/GetRecentPosPaymentsHandler.cs b/src/Pos.Transactions.Bff.Application/Handlers/GetRecentPosPaymentsHandler.cs new file mode 100644 index 0000000..f1f4df1 --- /dev/null +++ b/src/Pos.Transactions.Bff.Application/Handlers/GetRecentPosPaymentsHandler.cs @@ -0,0 +1,13 @@ +using Pos.Transactions.Bff.Application.Adapters; +using Pos.Transactions.Bff.Contracts.Requests; +using Pos.Transactions.Bff.Contracts.Responses; + +namespace Pos.Transactions.Bff.Application.Handlers; + +public sealed class GetRecentPosPaymentsHandler(IPosTransactionsServiceClient serviceClient) : IGetRecentPosPaymentsHandler +{ + public Task HandleAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken) + { + return serviceClient.FetchRecentPaymentsAsync(request, cancellationToken); + } +} diff --git a/src/Pos.Transactions.Bff.Application/Handlers/IGetPosTransactionDetailHandler.cs b/src/Pos.Transactions.Bff.Application/Handlers/IGetPosTransactionDetailHandler.cs new file mode 100644 index 0000000..b9b2bd4 --- /dev/null +++ b/src/Pos.Transactions.Bff.Application/Handlers/IGetPosTransactionDetailHandler.cs @@ -0,0 +1,9 @@ +using Pos.Transactions.Bff.Contracts.Requests; +using Pos.Transactions.Bff.Contracts.Responses; + +namespace Pos.Transactions.Bff.Application.Handlers; + +public interface IGetPosTransactionDetailHandler +{ + Task HandleAsync(GetPosTransactionDetailRequest request, CancellationToken cancellationToken); +} diff --git a/src/Pos.Transactions.Bff.Application/Handlers/IGetRecentPosPaymentsHandler.cs b/src/Pos.Transactions.Bff.Application/Handlers/IGetRecentPosPaymentsHandler.cs new file mode 100644 index 0000000..6bb3d2a --- /dev/null +++ b/src/Pos.Transactions.Bff.Application/Handlers/IGetRecentPosPaymentsHandler.cs @@ -0,0 +1,9 @@ +using Pos.Transactions.Bff.Contracts.Requests; +using Pos.Transactions.Bff.Contracts.Responses; + +namespace Pos.Transactions.Bff.Application.Handlers; + +public interface IGetRecentPosPaymentsHandler +{ + Task HandleAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken); +} diff --git a/src/Pos.Transactions.Bff.Contracts/Contracts/PosPaymentActivityContract.cs b/src/Pos.Transactions.Bff.Contracts/Contracts/PosPaymentActivityContract.cs new file mode 100644 index 0000000..41be78d --- /dev/null +++ b/src/Pos.Transactions.Bff.Contracts/Contracts/PosPaymentActivityContract.cs @@ -0,0 +1,9 @@ +namespace Pos.Transactions.Bff.Contracts.Contracts; + +public sealed record PosPaymentActivityContract( + string TransactionId, + string PaymentMethod, + decimal Amount, + string Currency, + string Status, + DateTime CapturedAtUtc); diff --git a/src/Pos.Transactions.Bff.Contracts/Requests/GetPosTransactionDetailRequest.cs b/src/Pos.Transactions.Bff.Contracts/Requests/GetPosTransactionDetailRequest.cs new file mode 100644 index 0000000..dc076df --- /dev/null +++ b/src/Pos.Transactions.Bff.Contracts/Requests/GetPosTransactionDetailRequest.cs @@ -0,0 +1,3 @@ +namespace Pos.Transactions.Bff.Contracts.Requests; + +public sealed record GetPosTransactionDetailRequest(string ContextId, string TransactionId); diff --git a/src/Pos.Transactions.Bff.Contracts/Responses/CapturePosPaymentResponse.cs b/src/Pos.Transactions.Bff.Contracts/Responses/CapturePosPaymentResponse.cs index 683f96a..f4c1114 100644 --- a/src/Pos.Transactions.Bff.Contracts/Responses/CapturePosPaymentResponse.cs +++ b/src/Pos.Transactions.Bff.Contracts/Responses/CapturePosPaymentResponse.cs @@ -4,4 +4,6 @@ public sealed record CapturePosPaymentResponse( string ContextId, string TransactionId, bool Succeeded, - string Summary); + string Summary, + string Status, + DateTime CapturedAtUtc); diff --git a/src/Pos.Transactions.Bff.Contracts/Responses/GetPosTransactionDetailResponse.cs b/src/Pos.Transactions.Bff.Contracts/Responses/GetPosTransactionDetailResponse.cs new file mode 100644 index 0000000..163979a --- /dev/null +++ b/src/Pos.Transactions.Bff.Contracts/Responses/GetPosTransactionDetailResponse.cs @@ -0,0 +1,10 @@ +using Pos.Transactions.Bff.Contracts.Contracts; + +namespace Pos.Transactions.Bff.Contracts.Responses; + +public sealed record GetPosTransactionDetailResponse( + string ContextId, + string Summary, + decimal OpenBalance, + string Currency, + PosPaymentActivityContract? Transaction); diff --git a/src/Pos.Transactions.Bff.Contracts/Responses/GetPosTransactionSummaryResponse.cs b/src/Pos.Transactions.Bff.Contracts/Responses/GetPosTransactionSummaryResponse.cs index 9bbc25e..d0e21ea 100644 --- a/src/Pos.Transactions.Bff.Contracts/Responses/GetPosTransactionSummaryResponse.cs +++ b/src/Pos.Transactions.Bff.Contracts/Responses/GetPosTransactionSummaryResponse.cs @@ -1,3 +1,10 @@ +using Pos.Transactions.Bff.Contracts.Contracts; + namespace Pos.Transactions.Bff.Contracts.Responses; -public sealed record GetPosTransactionSummaryResponse(string ContextId, string Summary); +public sealed record GetPosTransactionSummaryResponse( + string ContextId, + string Summary, + decimal OpenBalance, + string Currency, + IReadOnlyCollection RecentPayments); diff --git a/src/Pos.Transactions.Bff.Contracts/Responses/GetRecentPosPaymentsResponse.cs b/src/Pos.Transactions.Bff.Contracts/Responses/GetRecentPosPaymentsResponse.cs new file mode 100644 index 0000000..7654b35 --- /dev/null +++ b/src/Pos.Transactions.Bff.Contracts/Responses/GetRecentPosPaymentsResponse.cs @@ -0,0 +1,10 @@ +using Pos.Transactions.Bff.Contracts.Contracts; + +namespace Pos.Transactions.Bff.Contracts.Responses; + +public sealed record GetRecentPosPaymentsResponse( + string ContextId, + string Summary, + decimal OpenBalance, + string Currency, + IReadOnlyCollection RecentPayments); diff --git a/src/Pos.Transactions.Bff.Rest/Program.cs b/src/Pos.Transactions.Bff.Rest/Program.cs index 254eaa0..063a6be 100644 --- a/src/Pos.Transactions.Bff.Rest/Program.cs +++ b/src/Pos.Transactions.Bff.Rest/Program.cs @@ -10,8 +10,15 @@ const string SessionAccessCookieName = "thalos_session"; const string SessionRefreshCookieName = "thalos_refresh"; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddSingleton(); +builder.Services.AddHttpClient((serviceProvider, httpClient) => +{ + var configuration = serviceProvider.GetRequiredService(); + var operationsBaseAddress = configuration["OperationsService:BaseAddress"] ?? "http://operations-service:8080"; + httpClient.BaseAddress = new Uri($"{operationsBaseAddress.TrimEnd('/')}/"); +}); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient("ThalosAuth"); @@ -44,6 +51,43 @@ app.MapGet("/api/pos/transactions/summary", async ( return Results.Ok(await handler.HandleAsync(request, ct)); }); +app.MapGet("/api/pos/transactions/recent-payments", async ( + string contextId, + HttpContext context, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + IGetRecentPosPaymentsHandler handler, + CancellationToken ct) => +{ + var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct); + if (authError is not null) + { + return authError; + } + + var request = new GetPosTransactionSummaryRequest(contextId); + return Results.Ok(await handler.HandleAsync(request, ct)); +}); + +app.MapGet("/api/pos/transactions/{transactionId}", async ( + string transactionId, + string contextId, + HttpContext context, + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + IGetPosTransactionDetailHandler handler, + CancellationToken ct) => +{ + var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct); + if (authError is not null) + { + return authError; + } + + var request = new GetPosTransactionDetailRequest(contextId, transactionId); + return Results.Ok(await handler.HandleAsync(request, ct)); +}); + app.MapPost("/api/pos/transactions/payments", async ( CapturePosPaymentRequest request, HttpContext context, diff --git a/tests/Pos.Transactions.Bff.Application.UnitTests/OperationsPosTransactionsServiceClientTests.cs b/tests/Pos.Transactions.Bff.Application.UnitTests/OperationsPosTransactionsServiceClientTests.cs new file mode 100644 index 0000000..baf2d22 --- /dev/null +++ b/tests/Pos.Transactions.Bff.Application.UnitTests/OperationsPosTransactionsServiceClientTests.cs @@ -0,0 +1,110 @@ +using System.Net; +using System.Text; +using Pos.Transactions.Bff.Application.Adapters; +using Pos.Transactions.Bff.Contracts.Requests; + +namespace Pos.Transactions.Bff.Application.UnitTests; + +public sealed class OperationsPosTransactionsServiceClientTests +{ + [Fact] + public async Task FetchSummaryAsync_MapsBalanceCurrencyAndPayments() + { + var adapter = new OperationsPosTransactionsServiceClient(CreateClient(SummaryPayload)); + + var response = await adapter.FetchSummaryAsync(new GetPosTransactionSummaryRequest("demo-context"), CancellationToken.None); + + Assert.Equal(37.50m, response.OpenBalance); + Assert.Equal("USD", response.Currency); + Assert.Equal(2, response.RecentPayments.Count); + } + + [Fact] + public async Task FetchDetailAsync_ReturnsMatchingTransactionWhenPresent() + { + var adapter = new OperationsPosTransactionsServiceClient(CreateClient(SummaryPayload)); + + var response = await adapter.FetchDetailAsync(new GetPosTransactionDetailRequest("demo-context", "POS-9001"), CancellationToken.None); + + Assert.NotNull(response.Transaction); + Assert.Equal("POS-9001", response.Transaction!.TransactionId); + } + + [Fact] + public async Task FetchRecentPaymentsAsync_ReturnsProjectedPaymentHistory() + { + var adapter = new OperationsPosTransactionsServiceClient(CreateClient(SummaryPayload)); + + var response = await adapter.FetchRecentPaymentsAsync(new GetPosTransactionSummaryRequest("demo-context"), CancellationToken.None); + + Assert.Equal(2, response.RecentPayments.Count); + } + + [Fact] + public async Task CapturePaymentAsync_MapsCaptureWorkflowPayload() + { + var adapter = new OperationsPosTransactionsServiceClient(CreateClient(""" + { + "contextId": "demo-context", + "transactionId": "POS-9003", + "succeeded": true, + "summary": "Captured 19.95 USD using card.", + "status": "captured", + "capturedAtUtc": "2026-03-31T14:05:00Z" + } + """)); + + var response = await adapter.CapturePaymentAsync( + new CapturePosPaymentRequest("demo-context", "POS-9003", 19.95m, "USD", "card"), + CancellationToken.None); + + Assert.True(response.Succeeded); + Assert.Equal("captured", response.Status); + } + + private static HttpClient CreateClient(string json) + { + return new HttpClient(new StubHttpMessageHandler(json)) + { + BaseAddress = new Uri("http://operations-service:8080/") + }; + } + + private sealed class StubHttpMessageHandler(string json) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + } + } + + private const string SummaryPayload = """ + { + "contextId": "demo-context", + "summary": "Open POS balance reflects one captured payment and one pending settlement.", + "openBalance": 37.50, + "currency": "USD", + "recentPayments": [ + { + "transactionId": "POS-9001", + "paymentMethod": "card", + "amount": 25.50, + "currency": "USD", + "status": "captured", + "capturedAtUtc": "2026-03-31T13:30:00Z" + }, + { + "transactionId": "POS-9002", + "paymentMethod": "wallet", + "amount": 12.00, + "currency": "USD", + "status": "pending", + "capturedAtUtc": "2026-03-31T13:42:00Z" + } + ] + } + """; +} diff --git a/tests/Pos.Transactions.Bff.Application.UnitTests/Pos.Transactions.Bff.Application.UnitTests.csproj b/tests/Pos.Transactions.Bff.Application.UnitTests/Pos.Transactions.Bff.Application.UnitTests.csproj new file mode 100644 index 0000000..50f3eb8 --- /dev/null +++ b/tests/Pos.Transactions.Bff.Application.UnitTests/Pos.Transactions.Bff.Application.UnitTests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + +