Compare commits

..

No commits in common. "869248257bcde572af1364dedbbae90e248825fc" and "2ba208f27eed00cdfd4213b20a80aa94e2041fc3" have entirely different histories.

5 changed files with 37 additions and 95 deletions

View File

@ -21,11 +21,9 @@ 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
- 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 recent payment 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. - Correlation IDs are preserved through Thalos session checks and operations-service calls.

View File

@ -9,10 +9,11 @@ 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
- POS projection over shared restaurant payable-check rules. - Order lifecycle consistency and state transitions.
- POS transaction detail and recent payment activity views that stay aligned with served-and-payable restaurant state. - Kitchen queue and dispatch optimization hooks.
- 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,6 +39,5 @@ 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`, which projects payable shared-lifecycle checks from `operations-dal`. - Pos-transactions now delegates workflow snapshots to `operations-service`, but the upstream operations adapter still serves deterministic demo data rather than database-backed state.
- 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,14 +21,18 @@ 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 GetDetailPayloadAsync(request.ContextId, request.TransactionId, 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( return new GetPosTransactionDetailResponse(
payload.ContextId, payload.ContextId,
payload.Summary, payment is null
? $"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,
payload.Transaction is null ? null : MapPayment(payload.Transaction)); payment);
} }
public async Task<GetRecentPosPaymentsResponse> FetchRecentPaymentsAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken) public async Task<GetRecentPosPaymentsResponse> FetchRecentPaymentsAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken)
@ -65,18 +69,6 @@ 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)
@ -91,19 +83,14 @@ 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(MapPayment) .Select(static payment => new PosPaymentActivityContract(
.ToArray();
}
private static PosPaymentActivityContract MapPayment(PosPaymentActivityPayload payment)
{
return new PosPaymentActivityContract(
payment.TransactionId, payment.TransactionId,
payment.PaymentMethod, payment.PaymentMethod,
payment.Amount, payment.Amount,
payment.Currency, payment.Currency,
payment.Status, payment.Status,
payment.CapturedAtUtc); payment.CapturedAtUtc))
.ToArray();
} }
private sealed record GetPosTransactionSummaryPayload( private sealed record GetPosTransactionSummaryPayload(
@ -121,13 +108,6 @@ 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,10 +10,7 @@ public sealed class OperationsPosTransactionsServiceClientTests
[Fact] [Fact]
public async Task FetchSummaryAsync_MapsBalanceCurrencyAndPayments() public async Task FetchSummaryAsync_MapsBalanceCurrencyAndPayments()
{ {
var adapter = new OperationsPosTransactionsServiceClient(CreateClient(new Dictionary<string, string> var adapter = new OperationsPosTransactionsServiceClient(CreateClient(SummaryPayload));
{
["/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);
@ -25,25 +22,18 @@ public sealed class OperationsPosTransactionsServiceClientTests
[Fact] [Fact]
public async Task FetchDetailAsync_ReturnsMatchingTransactionWhenPresent() public async Task FetchDetailAsync_ReturnsMatchingTransactionWhenPresent()
{ {
var adapter = new OperationsPosTransactionsServiceClient(CreateClient(new Dictionary<string, string> var adapter = new OperationsPosTransactionsServiceClient(CreateClient(SummaryPayload));
{
["/internal/operations/pos/transactions/CHK-1002"] = DetailPayload
}));
var response = await adapter.FetchDetailAsync(new GetPosTransactionDetailRequest("demo-context", "CHK-1002"), CancellationToken.None); var response = await adapter.FetchDetailAsync(new GetPosTransactionDetailRequest("demo-context", "POS-9001"), CancellationToken.None);
Assert.NotNull(response.Transaction); Assert.NotNull(response.Transaction);
Assert.Equal("CHK-1002", response.Transaction!.TransactionId); Assert.Equal("POS-9001", 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(new Dictionary<string, string> var adapter = new OperationsPosTransactionsServiceClient(CreateClient(SummaryPayload));
{
["/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);
@ -53,9 +43,7 @@ public sealed class OperationsPosTransactionsServiceClientTests
[Fact] [Fact]
public async Task CapturePaymentAsync_MapsCaptureWorkflowPayload() public async Task CapturePaymentAsync_MapsCaptureWorkflowPayload()
{ {
var adapter = new OperationsPosTransactionsServiceClient(CreateClient(new Dictionary<string, string> var adapter = new OperationsPosTransactionsServiceClient(CreateClient("""
{
["/internal/operations/pos/payments"] = """
{ {
"contextId": "demo-context", "contextId": "demo-context",
"transactionId": "POS-9003", "transactionId": "POS-9003",
@ -64,8 +52,7 @@ 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"),
@ -75,24 +62,18 @@ public sealed class OperationsPosTransactionsServiceClientTests
Assert.Equal("captured", response.Status); Assert.Equal("captured", response.Status);
} }
private static HttpClient CreateClient(IReadOnlyDictionary<string, string> routes) private static HttpClient CreateClient(string json)
{ {
return new HttpClient(new StubHttpMessageHandler(routes)) return new HttpClient(new StubHttpMessageHandler(json))
{ {
BaseAddress = new Uri("http://operations-service:8080/") BaseAddress = new Uri("http://operations-service:8080/")
}; };
} }
private sealed class StubHttpMessageHandler(IReadOnlyDictionary<string, string> routes) : HttpMessageHandler private sealed class StubHttpMessageHandler(string json) : 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")
@ -103,44 +84,27 @@ public sealed class OperationsPosTransactionsServiceClientTests
private const string SummaryPayload = """ private const string SummaryPayload = """
{ {
"contextId": "demo-context", "contextId": "demo-context",
"summary": "2 payable checks are waiting for POS capture.", "summary": "Open POS balance reflects one captured payment and one pending settlement.",
"openBalance": 37.50, "openBalance": 37.50,
"currency": "USD", "currency": "USD",
"recentPayments": [ "recentPayments": [
{ {
"transactionId": "CHK-1002", "transactionId": "POS-9001",
"paymentMethod": "check", "paymentMethod": "card",
"amount": 25.50, "amount": 25.50,
"currency": "USD", "currency": "USD",
"status": "awaiting-payment", "status": "captured",
"capturedAtUtc": "2026-03-31T13:30:00Z" "capturedAtUtc": "2026-03-31T13:30:00Z"
}, },
{ {
"transactionId": "CHK-1003", "transactionId": "POS-9002",
"paymentMethod": "check", "paymentMethod": "wallet",
"amount": 12.00, "amount": 12.00,
"currency": "USD", "currency": "USD",
"status": "partial-payment", "status": "pending",
"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"
}
}
""";
} }