feat(pos-transactions-bff): align lifecycle transaction detail
This commit is contained in:
parent
2ea4e9f553
commit
869248257b
@ -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.
|
||||
|
||||
@ -21,18 +21,14 @@ public sealed class OperationsPosTransactionsServiceClient(HttpClient httpClient
|
||||
|
||||
public async Task<GetPosTransactionDetailResponse> 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<GetRecentPosPaymentsResponse> 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<GetPosTransactionDetailPayload> GetDetailPayloadAsync(
|
||||
string contextId,
|
||||
string transactionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = await httpClient.GetFromJsonAsync<GetPosTransactionDetailPayload>(
|
||||
$"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<CapturePosPaymentResponsePayload> CapturePaymentPayloadAsync(
|
||||
CapturePosPaymentRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
@ -83,16 +91,21 @@ public sealed class OperationsPosTransactionsServiceClient(HttpClient httpClient
|
||||
private static IReadOnlyCollection<PosPaymentActivityContract> MapPayments(IReadOnlyCollection<PosPaymentActivityPayload> 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,
|
||||
|
||||
@ -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<string, string>
|
||||
{
|
||||
["/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<string, string>
|
||||
{
|
||||
["/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<string, string>
|
||||
{
|
||||
["/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<string, string>
|
||||
{
|
||||
["/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<string, string> 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<string, string> routes) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> 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"
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user