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` - Default runtime target: `http://operations-service:8080`
- Internal upstream routes: - Internal upstream routes:
- `GET /internal/operations/pos/summary` - `GET /internal/operations/pos/summary`
- `GET /internal/operations/pos/transactions/{transactionId}`
- `POST /internal/operations/pos/payments` - `POST /internal/operations/pos/payments`
## Notes ## 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. - 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. - Epic 3: Improve observability and operational readiness for demo compose environments.
## Domain-Specific Candidate Features ## Domain-Specific Candidate Features
- Order lifecycle consistency and state transitions. - POS projection over shared restaurant payable-check rules.
- Kitchen queue and dispatch optimization hooks. - 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). - 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 ## Documentation Contract
Any code change in this repository must include docs updates in the same branch. 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` - Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml`
## Known Limitations ## 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. - 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) public async Task<GetPosTransactionDetailResponse> FetchDetailAsync(GetPosTransactionDetailRequest request, CancellationToken cancellationToken)
{ {
var payload = await GetSummaryPayloadAsync(request.ContextId, cancellationToken); var payload = await GetDetailPayloadAsync(request.ContextId, request.TransactionId, 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( return new GetPosTransactionDetailResponse(
payload.ContextId, payload.ContextId,
payment is null payload.Summary,
? $"Transaction {request.TransactionId} is not visible in the current POS summary."
: $"Transaction {request.TransactionId} is currently {payment.Status}.",
payload.OpenBalance, payload.OpenBalance,
payload.Currency, payload.Currency,
payment); payload.Transaction is null ? null : MapPayment(payload.Transaction));
} }
public async Task<GetRecentPosPaymentsResponse> FetchRecentPaymentsAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken) 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."); 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( private async Task<CapturePosPaymentResponsePayload> CapturePaymentPayloadAsync(
CapturePosPaymentRequest request, CapturePosPaymentRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@ -83,16 +91,21 @@ public sealed class OperationsPosTransactionsServiceClient(HttpClient httpClient
private static IReadOnlyCollection<PosPaymentActivityContract> MapPayments(IReadOnlyCollection<PosPaymentActivityPayload> payments) private static IReadOnlyCollection<PosPaymentActivityContract> MapPayments(IReadOnlyCollection<PosPaymentActivityPayload> payments)
{ {
return payments return payments
.Select(static payment => new PosPaymentActivityContract( .Select(MapPayment)
payment.TransactionId,
payment.PaymentMethod,
payment.Amount,
payment.Currency,
payment.Status,
payment.CapturedAtUtc))
.ToArray(); .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( private sealed record GetPosTransactionSummaryPayload(
string ContextId, string ContextId,
string Summary, string Summary,
@ -108,6 +121,13 @@ public sealed class OperationsPosTransactionsServiceClient(HttpClient httpClient
string Status, string Status,
DateTime CapturedAtUtc); DateTime CapturedAtUtc);
private sealed record GetPosTransactionDetailPayload(
string ContextId,
string Summary,
decimal OpenBalance,
string Currency,
PosPaymentActivityPayload? Transaction);
private sealed record CapturePosPaymentResponsePayload( private sealed record CapturePosPaymentResponsePayload(
string ContextId, string ContextId,
string TransactionId, string TransactionId,

View File

@ -10,7 +10,10 @@ public sealed class OperationsPosTransactionsServiceClientTests
[Fact] [Fact]
public async Task FetchSummaryAsync_MapsBalanceCurrencyAndPayments() 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); var response = await adapter.FetchSummaryAsync(new GetPosTransactionSummaryRequest("demo-context"), CancellationToken.None);
@ -22,18 +25,25 @@ public sealed class OperationsPosTransactionsServiceClientTests
[Fact] [Fact]
public async Task FetchDetailAsync_ReturnsMatchingTransactionWhenPresent() 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.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] [Fact]
public async Task FetchRecentPaymentsAsync_ReturnsProjectedPaymentHistory() 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); var response = await adapter.FetchRecentPaymentsAsync(new GetPosTransactionSummaryRequest("demo-context"), CancellationToken.None);
@ -43,7 +53,9 @@ public sealed class OperationsPosTransactionsServiceClientTests
[Fact] [Fact]
public async Task CapturePaymentAsync_MapsCaptureWorkflowPayload() 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", "contextId": "demo-context",
"transactionId": "POS-9003", "transactionId": "POS-9003",
@ -52,7 +64,8 @@ public sealed class OperationsPosTransactionsServiceClientTests
"status": "captured", "status": "captured",
"capturedAtUtc": "2026-03-31T14:05:00Z" "capturedAtUtc": "2026-03-31T14:05:00Z"
} }
""")); """
}));
var response = await adapter.CapturePaymentAsync( var response = await adapter.CapturePaymentAsync(
new CapturePosPaymentRequest("demo-context", "POS-9003", 19.95m, "USD", "card"), new CapturePosPaymentRequest("demo-context", "POS-9003", 19.95m, "USD", "card"),
@ -62,18 +75,24 @@ public sealed class OperationsPosTransactionsServiceClientTests
Assert.Equal("captured", response.Status); 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/") 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) 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) return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{ {
Content = new StringContent(json, Encoding.UTF8, "application/json") Content = new StringContent(json, Encoding.UTF8, "application/json")
@ -84,27 +103,44 @@ public sealed class OperationsPosTransactionsServiceClientTests
private const string SummaryPayload = """ private const string SummaryPayload = """
{ {
"contextId": "demo-context", "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, "openBalance": 37.50,
"currency": "USD", "currency": "USD",
"recentPayments": [ "recentPayments": [
{ {
"transactionId": "POS-9001", "transactionId": "CHK-1002",
"paymentMethod": "card", "paymentMethod": "check",
"amount": 25.50, "amount": 25.50,
"currency": "USD", "currency": "USD",
"status": "captured", "status": "awaiting-payment",
"capturedAtUtc": "2026-03-31T13:30:00Z" "capturedAtUtc": "2026-03-31T13:30:00Z"
}, },
{ {
"transactionId": "POS-9002", "transactionId": "CHK-1003",
"paymentMethod": "wallet", "paymentMethod": "check",
"amount": 12.00, "amount": 12.00,
"currency": "USD", "currency": "USD",
"status": "pending", "status": "partial-payment",
"capturedAtUtc": "2026-03-31T13:42:00Z" "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"
}
}
""";
} }