diff --git a/docs/api/pos-transaction-workflows.md b/docs/api/pos-transaction-workflows.md index dff254a..aec759e 100644 --- a/docs/api/pos-transaction-workflows.md +++ b/docs/api/pos-transaction-workflows.md @@ -21,10 +21,11 @@ This BFF exposes POS summary, transaction detail, recent payment activity, and p - Default runtime target: `http://operations-service:8080` - Internal upstream routes: - `GET /internal/operations/pos/summary` + - `GET /internal/operations/pos/transactions/{transactionId}` - `POST /internal/operations/pos/payments` ## Notes - POS summary and detail are derived from payable restaurant checks exposed by `operations-service`, not from an independent payment event store. -- Transaction detail is currently derived from the payable-check snapshot returned by `operations-service`. +- Transaction detail now comes from a dedicated lifecycle-backed upstream route instead of being projected from the summary snapshot. - Correlation IDs are preserved through Thalos session checks and operations-service calls. diff --git a/src/Pos.Transactions.Bff.Application/Adapters/OperationsPosTransactionsServiceClient.cs b/src/Pos.Transactions.Bff.Application/Adapters/OperationsPosTransactionsServiceClient.cs index b59c9da..33cf9b8 100644 --- a/src/Pos.Transactions.Bff.Application/Adapters/OperationsPosTransactionsServiceClient.cs +++ b/src/Pos.Transactions.Bff.Application/Adapters/OperationsPosTransactionsServiceClient.cs @@ -21,18 +21,14 @@ public sealed class OperationsPosTransactionsServiceClient(HttpClient httpClient public async Task FetchDetailAsync(GetPosTransactionDetailRequest request, CancellationToken cancellationToken) { - var payload = await GetSummaryPayloadAsync(request.ContextId, cancellationToken); - // POS detail remains a projection over the payable-check summary until operations-service exposes a dedicated detail route. - var payment = MapPayments(payload.RecentPayments).FirstOrDefault(activity => activity.TransactionId == request.TransactionId); + var payload = await GetDetailPayloadAsync(request.ContextId, request.TransactionId, cancellationToken); 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.Summary, payload.OpenBalance, payload.Currency, - payment); + payload.Transaction is null ? null : MapPayment(payload.Transaction)); } public async Task FetchRecentPaymentsAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken) @@ -69,6 +65,18 @@ public sealed class OperationsPosTransactionsServiceClient(HttpClient httpClient return payload ?? throw new InvalidOperationException("Operations service returned an empty POS summary payload."); } + private async Task GetDetailPayloadAsync( + string contextId, + string transactionId, + CancellationToken cancellationToken) + { + var payload = await httpClient.GetFromJsonAsync( + $"internal/operations/pos/transactions/{Uri.EscapeDataString(transactionId)}?contextId={Uri.EscapeDataString(contextId)}", + cancellationToken); + + return payload ?? throw new InvalidOperationException("Operations service returned an empty POS detail payload."); + } + private async Task CapturePaymentPayloadAsync( CapturePosPaymentRequest request, CancellationToken cancellationToken) @@ -83,16 +91,21 @@ public sealed class OperationsPosTransactionsServiceClient(HttpClient httpClient 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)) + .Select(MapPayment) .ToArray(); } + private static PosPaymentActivityContract MapPayment(PosPaymentActivityPayload payment) + { + return new PosPaymentActivityContract( + payment.TransactionId, + payment.PaymentMethod, + payment.Amount, + payment.Currency, + payment.Status, + payment.CapturedAtUtc); + } + private sealed record GetPosTransactionSummaryPayload( string ContextId, string Summary, @@ -108,6 +121,13 @@ public sealed class OperationsPosTransactionsServiceClient(HttpClient httpClient string Status, DateTime CapturedAtUtc); + private sealed record GetPosTransactionDetailPayload( + string ContextId, + string Summary, + decimal OpenBalance, + string Currency, + PosPaymentActivityPayload? Transaction); + private sealed record CapturePosPaymentResponsePayload( string ContextId, string TransactionId, diff --git a/tests/Pos.Transactions.Bff.Application.UnitTests/OperationsPosTransactionsServiceClientTests.cs b/tests/Pos.Transactions.Bff.Application.UnitTests/OperationsPosTransactionsServiceClientTests.cs index 173e89d..2217f7b 100644 --- a/tests/Pos.Transactions.Bff.Application.UnitTests/OperationsPosTransactionsServiceClientTests.cs +++ b/tests/Pos.Transactions.Bff.Application.UnitTests/OperationsPosTransactionsServiceClientTests.cs @@ -10,7 +10,10 @@ public sealed class OperationsPosTransactionsServiceClientTests [Fact] public async Task FetchSummaryAsync_MapsBalanceCurrencyAndPayments() { - var adapter = new OperationsPosTransactionsServiceClient(CreateClient(SummaryPayload)); + var adapter = new OperationsPosTransactionsServiceClient(CreateClient(new Dictionary + { + ["/internal/operations/pos/summary"] = SummaryPayload + })); var response = await adapter.FetchSummaryAsync(new GetPosTransactionSummaryRequest("demo-context"), CancellationToken.None); @@ -22,7 +25,10 @@ public sealed class OperationsPosTransactionsServiceClientTests [Fact] public async Task FetchDetailAsync_ReturnsMatchingTransactionWhenPresent() { - var adapter = new OperationsPosTransactionsServiceClient(CreateClient(SummaryPayload)); + var adapter = new OperationsPosTransactionsServiceClient(CreateClient(new Dictionary + { + ["/internal/operations/pos/transactions/CHK-1002"] = DetailPayload + })); var response = await adapter.FetchDetailAsync(new GetPosTransactionDetailRequest("demo-context", "CHK-1002"), CancellationToken.None); @@ -34,7 +40,10 @@ public sealed class OperationsPosTransactionsServiceClientTests [Fact] public async Task FetchRecentPaymentsAsync_ReturnsProjectedPaymentHistory() { - var adapter = new OperationsPosTransactionsServiceClient(CreateClient(SummaryPayload)); + var adapter = new OperationsPosTransactionsServiceClient(CreateClient(new Dictionary + { + ["/internal/operations/pos/summary"] = SummaryPayload + })); var response = await adapter.FetchRecentPaymentsAsync(new GetPosTransactionSummaryRequest("demo-context"), CancellationToken.None); @@ -44,7 +53,9 @@ public sealed class OperationsPosTransactionsServiceClientTests [Fact] public async Task CapturePaymentAsync_MapsCaptureWorkflowPayload() { - var adapter = new OperationsPosTransactionsServiceClient(CreateClient(""" + var adapter = new OperationsPosTransactionsServiceClient(CreateClient(new Dictionary + { + ["/internal/operations/pos/payments"] = """ { "contextId": "demo-context", "transactionId": "POS-9003", @@ -53,7 +64,8 @@ public sealed class OperationsPosTransactionsServiceClientTests "status": "captured", "capturedAtUtc": "2026-03-31T14:05:00Z" } - """)); + """ + })); var response = await adapter.CapturePaymentAsync( new CapturePosPaymentRequest("demo-context", "POS-9003", 19.95m, "USD", "card"), @@ -63,18 +75,24 @@ public sealed class OperationsPosTransactionsServiceClientTests Assert.Equal("captured", response.Status); } - private static HttpClient CreateClient(string json) + private static HttpClient CreateClient(IReadOnlyDictionary routes) { - return new HttpClient(new StubHttpMessageHandler(json)) + return new HttpClient(new StubHttpMessageHandler(routes)) { BaseAddress = new Uri("http://operations-service:8080/") }; } - private sealed class StubHttpMessageHandler(string json) : HttpMessageHandler + private sealed class StubHttpMessageHandler(IReadOnlyDictionary routes) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + var path = request.RequestUri?.AbsolutePath ?? string.Empty; + if (!routes.TryGetValue(path, out var json)) + { + throw new InvalidOperationException($"No stub payload configured for '{path}'."); + } + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") @@ -108,4 +126,21 @@ public sealed class OperationsPosTransactionsServiceClientTests ] } """; + + private const string DetailPayload = """ + { + "contextId": "demo-context", + "summary": "Transaction CHK-1002 maps to check CHK-1002 and is currently awaiting-payment.", + "openBalance": 37.50, + "currency": "USD", + "transaction": { + "transactionId": "CHK-1002", + "paymentMethod": "check", + "amount": 25.50, + "currency": "USD", + "status": "awaiting-payment", + "capturedAtUtc": "2026-03-31T13:30:00Z" + } + } + """; }