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.Contracts/Pos.Transactions.Bff.Contracts.csproj" />
<Project Path="src/Pos.Transactions.Bff.Rest/Pos.Transactions.Bff.Rest.csproj" /> <Project Path="src/Pos.Transactions.Bff.Rest/Pos.Transactions.Bff.Rest.csproj" />
</Folder> </Folder>
<Folder Name="/tests/">
<Project Path="tests/Pos.Transactions.Bff.Application.UnitTests/Pos.Transactions.Bff.Application.UnitTests.csproj" />
</Folder>
</Solution> </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. - Kitchen queue and dispatch optimization hooks.
- Operations control-plane policies (flags, service windows, overrides). - Operations control-plane policies (flags, service windows, overrides).
- POS closeout and settlement summary alignment. - 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

@ -22,7 +22,8 @@ docker run --rm -p 8080:8080 --name pos-transactions-bff agilewebs/pos-transacti
## Runtime Notes ## 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. - Requires `ThalosAuth__BaseAddress` to resolve Thalos session introspection endpoint.
- Returns standardized auth failures (`401|403|503`) with `x-correlation-id` propagation. - 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` - Integration artifact path: `greenfield/demo/restaurant/docker-compose.yml`
## Known Limitations ## 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. - 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 ## Protected Endpoints
- `/api/pos/transactions/summary` - `/api/pos/transactions/summary`
- `/api/pos/transactions/recent-payments`
- `/api/pos/transactions/{transactionId}`
- `/api/pos/transactions/payments` - `/api/pos/transactions/payments`
## Anonymous Endpoints ## 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 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); 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) 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 ContextId,
string TransactionId, string TransactionId,
bool Succeeded, 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; 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"; const string SessionRefreshCookieName = "thalos_refresh";
var builder = WebApplication.CreateBuilder(args); 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<IGetPosTransactionSummaryHandler, GetPosTransactionSummaryHandler>();
builder.Services.AddSingleton<IGetPosTransactionDetailHandler, GetPosTransactionDetailHandler>();
builder.Services.AddSingleton<IGetRecentPosPaymentsHandler, GetRecentPosPaymentsHandler>();
builder.Services.AddSingleton<ICapturePosPaymentHandler, CapturePosPaymentHandler>(); builder.Services.AddSingleton<ICapturePosPaymentHandler, CapturePosPaymentHandler>();
builder.Services.AddHttpClient("ThalosAuth"); builder.Services.AddHttpClient("ThalosAuth");
@ -44,6 +51,43 @@ app.MapGet("/api/pos/transactions/summary", async (
return Results.Ok(await handler.HandleAsync(request, ct)); 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 ( app.MapPost("/api/pos/transactions/payments", async (
CapturePosPaymentRequest request, CapturePosPaymentRequest request,
HttpContext context, 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>