feat(pos-transactions-bff): add pos workflow endpoints

This commit is contained in:
José René White Enciso 2026-03-31 16:33:40 -06:00
parent a4e5f0252d
commit 2ba208f27e
22 changed files with 421 additions and 24 deletions

View File

@ -4,4 +4,7 @@
<Project Path="src/Pos.Transactions.Bff.Contracts/Pos.Transactions.Bff.Contracts.csproj" />
<Project Path="src/Pos.Transactions.Bff.Rest/Pos.Transactions.Bff.Rest.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Pos.Transactions.Bff.Application.UnitTests/Pos.Transactions.Bff.Application.UnitTests.csproj" />
</Folder>
</Solution>

View File

@ -0,0 +1,29 @@
# POS Transaction Workflow API
## Purpose
This BFF exposes POS summary, transaction detail, recent payment activity, and payment capture workflows over REST while delegating orchestration to `operations-service`.
## Endpoints
- `GET /api/pos/transactions/summary?contextId=<value>`
- Returns POS balance and recent payment summary.
- `GET /api/pos/transactions/recent-payments?contextId=<value>`
- Returns the recent POS payment activity snapshot.
- `GET /api/pos/transactions/{transactionId}?contextId=<value>`
- Returns the current detail projection for a single recent payment activity item.
- `POST /api/pos/transactions/payments`
- Captures a POS payment.
## Upstream Dependency
- Base address configuration: `OperationsService:BaseAddress`
- Default runtime target: `http://operations-service:8080`
- Internal upstream routes:
- `GET /internal/operations/pos/summary`
- `POST /internal/operations/pos/payments`
## Notes
- Transaction detail is currently derived from the recent payment snapshot returned by `operations-service`.
- Correlation IDs are preserved through Thalos session checks and operations-service calls.

View File

@ -13,6 +13,7 @@ pos-transactions-bff
- Kitchen queue and dispatch optimization hooks.
- 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

@ -22,7 +22,8 @@ docker run --rm -p 8080:8080 --name pos-transactions-bff agilewebs/pos-transacti
## Runtime Notes
- Exposes REST edge endpoints for transaction summary and payment capture.
- Exposes REST edge endpoints for transaction summary, transaction detail, recent payment activity, and payment capture.
- Requires `OperationsService__BaseAddress` to resolve the upstream operations-service runtime.
- Requires `ThalosAuth__BaseAddress` to resolve Thalos session introspection endpoint.
- Returns standardized auth failures (`401|403|503`) with `x-correlation-id` propagation.
@ -38,5 +39,5 @@ docker run --rm -p 8080:8080 --name pos-transactions-bff agilewebs/pos-transacti
- Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml`
## Known Limitations
- Current runtime adapters are still predominantly in-memory for deterministic local/demo behavior.
- Pos-transactions now delegates workflow snapshots to `operations-service`, but the upstream operations adapter still serves deterministic demo data rather than database-backed state.
- Demo PostgreSQL seeds validate integration contracts and smoke determinism, but do not yet imply full persistence implementation parity.

View File

@ -7,6 +7,8 @@ This BFF enforces authenticated access on business endpoints using Thalos sessio
## Protected Endpoints
- `/api/pos/transactions/summary`
- `/api/pos/transactions/recent-payments`
- `/api/pos/transactions/{transactionId}`
- `/api/pos/transactions/payments`
## Anonymous Endpoints

View File

@ -1,17 +0,0 @@
using Pos.Transactions.Bff.Contracts.Requests;
using Pos.Transactions.Bff.Contracts.Responses;
namespace Pos.Transactions.Bff.Application.Adapters;
public sealed class DefaultPosTransactionsServiceClient : IPosTransactionsServiceClient
{
public Task<GetPosTransactionSummaryResponse> FetchAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken)
{
return Task.FromResult(new GetPosTransactionSummaryResponse(request.ContextId, "Default service-backed response."));
}
public Task<CapturePosPaymentResponse> CapturePaymentAsync(CapturePosPaymentRequest request, CancellationToken cancellationToken)
{
return Task.FromResult(new CapturePosPaymentResponse(request.ContextId, request.TransactionId, true, "Payment captured by default adapter."));
}
}

View File

@ -5,6 +5,8 @@ namespace Pos.Transactions.Bff.Application.Adapters;
public interface IPosTransactionsServiceClient
{
Task<GetPosTransactionSummaryResponse> FetchAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken);
Task<GetPosTransactionSummaryResponse> FetchSummaryAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken);
Task<GetPosTransactionDetailResponse> FetchDetailAsync(GetPosTransactionDetailRequest request, CancellationToken cancellationToken);
Task<GetRecentPosPaymentsResponse> FetchRecentPaymentsAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken);
Task<CapturePosPaymentResponse> CapturePaymentAsync(CapturePosPaymentRequest request, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,118 @@
using System.Net.Http.Json;
using Pos.Transactions.Bff.Contracts.Contracts;
using Pos.Transactions.Bff.Contracts.Requests;
using Pos.Transactions.Bff.Contracts.Responses;
namespace Pos.Transactions.Bff.Application.Adapters;
public sealed class OperationsPosTransactionsServiceClient(HttpClient httpClient) : IPosTransactionsServiceClient
{
public async Task<GetPosTransactionSummaryResponse> FetchSummaryAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken)
{
var payload = await GetSummaryPayloadAsync(request.ContextId, cancellationToken);
return new GetPosTransactionSummaryResponse(
payload.ContextId,
payload.Summary,
payload.OpenBalance,
payload.Currency,
MapPayments(payload.RecentPayments));
}
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);
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.OpenBalance,
payload.Currency,
payment);
}
public async Task<GetRecentPosPaymentsResponse> FetchRecentPaymentsAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken)
{
var payload = await GetSummaryPayloadAsync(request.ContextId, cancellationToken);
return new GetRecentPosPaymentsResponse(
payload.ContextId,
payload.Summary,
payload.OpenBalance,
payload.Currency,
MapPayments(payload.RecentPayments));
}
public async Task<CapturePosPaymentResponse> CapturePaymentAsync(CapturePosPaymentRequest request, CancellationToken cancellationToken)
{
var payload = await CapturePaymentPayloadAsync(request, cancellationToken);
return new CapturePosPaymentResponse(
payload.ContextId,
payload.TransactionId,
payload.Succeeded,
payload.Summary,
payload.Status,
payload.CapturedAtUtc);
}
private async Task<GetPosTransactionSummaryPayload> GetSummaryPayloadAsync(string contextId, CancellationToken cancellationToken)
{
var payload = await httpClient.GetFromJsonAsync<GetPosTransactionSummaryPayload>(
$"internal/operations/pos/summary?contextId={Uri.EscapeDataString(contextId)}",
cancellationToken);
return payload ?? throw new InvalidOperationException("Operations service returned an empty POS summary payload.");
}
private async Task<CapturePosPaymentResponsePayload> CapturePaymentPayloadAsync(
CapturePosPaymentRequest request,
CancellationToken cancellationToken)
{
using var response = await httpClient.PostAsJsonAsync("internal/operations/pos/payments", request, cancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<CapturePosPaymentResponsePayload>(cancellationToken);
return payload ?? throw new InvalidOperationException("Operations service returned an empty POS capture payload.");
}
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))
.ToArray();
}
private sealed record GetPosTransactionSummaryPayload(
string ContextId,
string Summary,
decimal OpenBalance,
string Currency,
IReadOnlyCollection<PosPaymentActivityPayload> RecentPayments);
private sealed record PosPaymentActivityPayload(
string TransactionId,
string PaymentMethod,
decimal Amount,
string Currency,
string Status,
DateTime CapturedAtUtc);
private sealed record CapturePosPaymentResponsePayload(
string ContextId,
string TransactionId,
bool Succeeded,
string Summary,
string Status,
DateTime CapturedAtUtc);
}

View File

@ -0,0 +1,13 @@
using Pos.Transactions.Bff.Application.Adapters;
using Pos.Transactions.Bff.Contracts.Requests;
using Pos.Transactions.Bff.Contracts.Responses;
namespace Pos.Transactions.Bff.Application.Handlers;
public sealed class GetPosTransactionDetailHandler(IPosTransactionsServiceClient serviceClient) : IGetPosTransactionDetailHandler
{
public Task<GetPosTransactionDetailResponse> HandleAsync(GetPosTransactionDetailRequest request, CancellationToken cancellationToken)
{
return serviceClient.FetchDetailAsync(request, cancellationToken);
}
}

View File

@ -8,6 +8,6 @@ public sealed class GetPosTransactionSummaryHandler(IPosTransactionsServiceClien
{
public Task<GetPosTransactionSummaryResponse> HandleAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken)
{
return serviceClient.FetchAsync(request, cancellationToken);
return serviceClient.FetchSummaryAsync(request, cancellationToken);
}
}

View File

@ -0,0 +1,13 @@
using Pos.Transactions.Bff.Application.Adapters;
using Pos.Transactions.Bff.Contracts.Requests;
using Pos.Transactions.Bff.Contracts.Responses;
namespace Pos.Transactions.Bff.Application.Handlers;
public sealed class GetRecentPosPaymentsHandler(IPosTransactionsServiceClient serviceClient) : IGetRecentPosPaymentsHandler
{
public Task<GetRecentPosPaymentsResponse> HandleAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken)
{
return serviceClient.FetchRecentPaymentsAsync(request, cancellationToken);
}
}

View File

@ -0,0 +1,9 @@
using Pos.Transactions.Bff.Contracts.Requests;
using Pos.Transactions.Bff.Contracts.Responses;
namespace Pos.Transactions.Bff.Application.Handlers;
public interface IGetPosTransactionDetailHandler
{
Task<GetPosTransactionDetailResponse> HandleAsync(GetPosTransactionDetailRequest request, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,9 @@
using Pos.Transactions.Bff.Contracts.Requests;
using Pos.Transactions.Bff.Contracts.Responses;
namespace Pos.Transactions.Bff.Application.Handlers;
public interface IGetRecentPosPaymentsHandler
{
Task<GetRecentPosPaymentsResponse> HandleAsync(GetPosTransactionSummaryRequest request, CancellationToken cancellationToken);
}

View File

@ -0,0 +1,9 @@
namespace Pos.Transactions.Bff.Contracts.Contracts;
public sealed record PosPaymentActivityContract(
string TransactionId,
string PaymentMethod,
decimal Amount,
string Currency,
string Status,
DateTime CapturedAtUtc);

View File

@ -0,0 +1,3 @@
namespace Pos.Transactions.Bff.Contracts.Requests;
public sealed record GetPosTransactionDetailRequest(string ContextId, string TransactionId);

View File

@ -4,4 +4,6 @@ public sealed record CapturePosPaymentResponse(
string ContextId,
string TransactionId,
bool Succeeded,
string Summary);
string Summary,
string Status,
DateTime CapturedAtUtc);

View File

@ -0,0 +1,10 @@
using Pos.Transactions.Bff.Contracts.Contracts;
namespace Pos.Transactions.Bff.Contracts.Responses;
public sealed record GetPosTransactionDetailResponse(
string ContextId,
string Summary,
decimal OpenBalance,
string Currency,
PosPaymentActivityContract? Transaction);

View File

@ -1,3 +1,10 @@
using Pos.Transactions.Bff.Contracts.Contracts;
namespace Pos.Transactions.Bff.Contracts.Responses;
public sealed record GetPosTransactionSummaryResponse(string ContextId, string Summary);
public sealed record GetPosTransactionSummaryResponse(
string ContextId,
string Summary,
decimal OpenBalance,
string Currency,
IReadOnlyCollection<PosPaymentActivityContract> RecentPayments);

View File

@ -0,0 +1,10 @@
using Pos.Transactions.Bff.Contracts.Contracts;
namespace Pos.Transactions.Bff.Contracts.Responses;
public sealed record GetRecentPosPaymentsResponse(
string ContextId,
string Summary,
decimal OpenBalance,
string Currency,
IReadOnlyCollection<PosPaymentActivityContract> RecentPayments);

View File

@ -10,8 +10,15 @@ const string SessionAccessCookieName = "thalos_session";
const string SessionRefreshCookieName = "thalos_refresh";
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IPosTransactionsServiceClient, DefaultPosTransactionsServiceClient>();
builder.Services.AddHttpClient<IPosTransactionsServiceClient, OperationsPosTransactionsServiceClient>((serviceProvider, httpClient) =>
{
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
var operationsBaseAddress = configuration["OperationsService:BaseAddress"] ?? "http://operations-service:8080";
httpClient.BaseAddress = new Uri($"{operationsBaseAddress.TrimEnd('/')}/");
});
builder.Services.AddSingleton<IGetPosTransactionSummaryHandler, GetPosTransactionSummaryHandler>();
builder.Services.AddSingleton<IGetPosTransactionDetailHandler, GetPosTransactionDetailHandler>();
builder.Services.AddSingleton<IGetRecentPosPaymentsHandler, GetRecentPosPaymentsHandler>();
builder.Services.AddSingleton<ICapturePosPaymentHandler, CapturePosPaymentHandler>();
builder.Services.AddHttpClient("ThalosAuth");
@ -44,6 +51,43 @@ app.MapGet("/api/pos/transactions/summary", async (
return Results.Ok(await handler.HandleAsync(request, ct));
});
app.MapGet("/api/pos/transactions/recent-payments", async (
string contextId,
HttpContext context,
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
IGetRecentPosPaymentsHandler handler,
CancellationToken ct) =>
{
var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct);
if (authError is not null)
{
return authError;
}
var request = new GetPosTransactionSummaryRequest(contextId);
return Results.Ok(await handler.HandleAsync(request, ct));
});
app.MapGet("/api/pos/transactions/{transactionId}", async (
string transactionId,
string contextId,
HttpContext context,
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
IGetPosTransactionDetailHandler handler,
CancellationToken ct) =>
{
var authError = await EnforceSessionAsync(context, httpClientFactory, configuration, ct);
if (authError is not null)
{
return authError;
}
var request = new GetPosTransactionDetailRequest(contextId, transactionId);
return Results.Ok(await handler.HandleAsync(request, ct));
});
app.MapPost("/api/pos/transactions/payments", async (
CapturePosPaymentRequest request,
HttpContext context,

View File

@ -0,0 +1,110 @@
using System.Net;
using System.Text;
using Pos.Transactions.Bff.Application.Adapters;
using Pos.Transactions.Bff.Contracts.Requests;
namespace Pos.Transactions.Bff.Application.UnitTests;
public sealed class OperationsPosTransactionsServiceClientTests
{
[Fact]
public async Task FetchSummaryAsync_MapsBalanceCurrencyAndPayments()
{
var adapter = new OperationsPosTransactionsServiceClient(CreateClient(SummaryPayload));
var response = await adapter.FetchSummaryAsync(new GetPosTransactionSummaryRequest("demo-context"), CancellationToken.None);
Assert.Equal(37.50m, response.OpenBalance);
Assert.Equal("USD", response.Currency);
Assert.Equal(2, response.RecentPayments.Count);
}
[Fact]
public async Task FetchDetailAsync_ReturnsMatchingTransactionWhenPresent()
{
var adapter = new OperationsPosTransactionsServiceClient(CreateClient(SummaryPayload));
var response = await adapter.FetchDetailAsync(new GetPosTransactionDetailRequest("demo-context", "POS-9001"), CancellationToken.None);
Assert.NotNull(response.Transaction);
Assert.Equal("POS-9001", response.Transaction!.TransactionId);
}
[Fact]
public async Task FetchRecentPaymentsAsync_ReturnsProjectedPaymentHistory()
{
var adapter = new OperationsPosTransactionsServiceClient(CreateClient(SummaryPayload));
var response = await adapter.FetchRecentPaymentsAsync(new GetPosTransactionSummaryRequest("demo-context"), CancellationToken.None);
Assert.Equal(2, response.RecentPayments.Count);
}
[Fact]
public async Task CapturePaymentAsync_MapsCaptureWorkflowPayload()
{
var adapter = new OperationsPosTransactionsServiceClient(CreateClient("""
{
"contextId": "demo-context",
"transactionId": "POS-9003",
"succeeded": true,
"summary": "Captured 19.95 USD using card.",
"status": "captured",
"capturedAtUtc": "2026-03-31T14:05:00Z"
}
"""));
var response = await adapter.CapturePaymentAsync(
new CapturePosPaymentRequest("demo-context", "POS-9003", 19.95m, "USD", "card"),
CancellationToken.None);
Assert.True(response.Succeeded);
Assert.Equal("captured", response.Status);
}
private static HttpClient CreateClient(string json)
{
return new HttpClient(new StubHttpMessageHandler(json))
{
BaseAddress = new Uri("http://operations-service:8080/")
};
}
private sealed class StubHttpMessageHandler(string json) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
});
}
}
private const string SummaryPayload = """
{
"contextId": "demo-context",
"summary": "Open POS balance reflects one captured payment and one pending settlement.",
"openBalance": 37.50,
"currency": "USD",
"recentPayments": [
{
"transactionId": "POS-9001",
"paymentMethod": "card",
"amount": 25.50,
"currency": "USD",
"status": "captured",
"capturedAtUtc": "2026-03-31T13:30:00Z"
},
{
"transactionId": "POS-9002",
"paymentMethod": "wallet",
"amount": 12.00,
"currency": "USD",
"status": "pending",
"capturedAtUtc": "2026-03-31T13:42:00Z"
}
]
}
""";
}

View File

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/Pos.Transactions.Bff.Application/Pos.Transactions.Bff.Application.csproj" />
</ItemGroup>
</Project>