Compare commits

..

2 Commits

Author SHA1 Message Date
José René White Enciso
869248257b feat(pos-transactions-bff): align lifecycle transaction detail 2026-03-31 19:59:32 -06:00
José René White Enciso
2ea4e9f553 docs(pos-transactions-bff): align payable check workflows 2026-03-31 18:54:42 -06:00
5 changed files with 95 additions and 37 deletions

View File

@ -21,9 +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
- Transaction detail is currently derived from the recent payment snapshot returned by `operations-service`.
- POS summary and detail are derived from payable restaurant checks exposed by `operations-service`, not from an independent payment event store.
- 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.

View File

@ -9,11 +9,10 @@ pos-transactions-bff
- Epic 3: Improve observability and operational readiness for demo compose environments.
## Domain-Specific Candidate Features
- Order lifecycle consistency and state transitions.
- Kitchen queue and dispatch optimization hooks.
- POS projection over shared restaurant payable-check rules.
- POS transaction detail and recent payment activity views that stay aligned with served-and-payable restaurant state.
- Cross-app continuity from kitchen completion to payment eligibility and final capture.
- 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.

View File

@ -39,5 +39,6 @@ docker run --rm -p 8080:8080 --name pos-transactions-bff agilewebs/pos-transacti
- Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml`
## Known Limitations
- Pos-transactions now delegates workflow snapshots to `operations-service`, but the upstream operations adapter still serves deterministic demo data rather than database-backed state.
- Pos-transactions now delegates workflow snapshots to `operations-service`, which projects payable shared-lifecycle checks from `operations-dal`.
- Transaction detail remains a summary projection until a dedicated payable-check detail contract is exposed upstream.
- Demo PostgreSQL seeds validate integration contracts and smoke determinism, but do not yet imply full persistence implementation parity.

View File

@ -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);
// 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);
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,14 +91,19 @@ public sealed class OperationsPosTransactionsServiceClient(HttpClient httpClient
private static IReadOnlyCollection<PosPaymentActivityContract> MapPayments(IReadOnlyCollection<PosPaymentActivityPayload> payments)
{
return payments
.Select(static payment => new PosPaymentActivityContract(
.Select(MapPayment)
.ToArray();
}
private static PosPaymentActivityContract MapPayment(PosPaymentActivityPayload payment)
{
return new PosPaymentActivityContract(
payment.TransactionId,
payment.PaymentMethod,
payment.Amount,
payment.Currency,
payment.Status,
payment.CapturedAtUtc))
.ToArray();
payment.CapturedAtUtc);
}
private sealed record GetPosTransactionSummaryPayload(
@ -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,

View File

@ -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,18 +25,25 @@ 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", "POS-9001"), CancellationToken.None);
var response = await adapter.FetchDetailAsync(new GetPosTransactionDetailRequest("demo-context", "CHK-1002"), CancellationToken.None);
Assert.NotNull(response.Transaction);
Assert.Equal("POS-9001", response.Transaction!.TransactionId);
Assert.Equal("CHK-1002", response.Transaction!.TransactionId);
Assert.Equal("awaiting-payment", response.Transaction.Status);
}
[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);
@ -43,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",
@ -52,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"),
@ -62,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")
@ -84,27 +103,44 @@ public sealed class OperationsPosTransactionsServiceClientTests
private const string SummaryPayload = """
{
"contextId": "demo-context",
"summary": "Open POS balance reflects one captured payment and one pending settlement.",
"summary": "2 payable checks are waiting for POS capture.",
"openBalance": 37.50,
"currency": "USD",
"recentPayments": [
{
"transactionId": "POS-9001",
"paymentMethod": "card",
"transactionId": "CHK-1002",
"paymentMethod": "check",
"amount": 25.50,
"currency": "USD",
"status": "captured",
"status": "awaiting-payment",
"capturedAtUtc": "2026-03-31T13:30:00Z"
},
{
"transactionId": "POS-9002",
"paymentMethod": "wallet",
"transactionId": "CHK-1003",
"paymentMethod": "check",
"amount": 12.00,
"currency": "USD",
"status": "pending",
"status": "partial-payment",
"capturedAtUtc": "2026-03-31T13:42:00Z"
}
]
}
""";
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"
}
}
""";
}