Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,29 @@ manually.
architecture; gRPC/OpenAPI/NuGet contract packages marked out of scope.
- `docs/architecture.md`: corrected the Service Bus queue inventory to match `AppHost.cs`.
- `docs/deploy-ordersphere.md`: fixed step numbering.
- `Order` status transitions (`Confirm`, `MarkShipped`, `MarkDelivered`, `Cancel`) and
`PaymentRecord` transitions return `Result` and reject invalid transitions instead of throwing
or applying them.
- `OrderStatusChangedIntegrationEvent` carries an optional `CustomerId` (additive).
- Stripe client: 30 s timeout with 2 network retries (SDK default 80 s), keeping one payment run
inside the Service Bus lock renewal window.

### Fixed
- Stripe calls carry deterministic idempotency keys; a redelivered payment request no longer
creates a second PaymentIntent. Only declines and invalid requests are reported as failures;
transient faults are retried through Service Bus redelivery.
- A failed capture releases the authorization and keeps the PaymentIntent id on the payment record.
- The Stripe webhook is reachable (`/webhooks/stripe` on the BFF), finds payments by order or intent,
asks Stripe to retry while the record does not exist yet, rejects a missing signature with 400
instead of 500, and applies only valid status transitions.
- A payment result for an already cancelled order no longer re-confirms it; a captured payment on a
cancelled order is refunded.
- Admin coupon management is routed through the API Gateway (was 404).

### Security
- Webhook subscriptions only receive events of their own customer (previously every subscriber of
an event type received all customers' events).
- Webhook target URLs are restricted to public HTTPS hosts, checked on save and again for every
resolved address at connect time; redirects are not followed; failed deliveries no longer store
the target's response body.
- `/bff/login` only accepts a local `returnUrl` (open redirect).
26 changes: 26 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,32 @@ the platform metric alert (`Service Bus DLQ`, row above) remains the source of t
inbox (`EfInboxStore` only marks on success), so the replayed message is reprocessed normally.
5. If the root cause is not fixed, the message dead-letters again — replay batches are capped
(`DlqAdminOptions.ReplayBatchLimit`, default 50) to avoid a replay storm.
6. **Payment queues with Stripe** (`payment-requests`, `order-confirmation-failed`,
`refund-requested`): every Stripe call carries an idempotency key derived from the order or
intent, and Stripe replays the stored result for at least 24 hours. Replay within 24 hours of the
original failure. After that, check the order's PaymentIntent in the Stripe dashboard first
(search by metadata `orderId`): Stripe no longer deduplicates, and a create/refund that already
succeeded would run a second time.

### Stripe webhook

Stripe delivers events to the BFF at `POST https://<bff-host>/webhooks/stripe` — the endpoint to
register in the Stripe dashboard (or `stripe listen --forward-to https://localhost:<bff-port>/webhooks/stripe`
locally). The route is anonymous on the BFF and the API Gateway and outside `/api`, so neither the
session policy nor the CSRF check applies; the Payment API authenticates each request by its
`Stripe-Signature` against `Stripe:WebhookSecret`.

The endpoint answers:

| Status | Meaning |
|---|---|
| 200 | Reconciled, already reconciled, out of date, or not an OrderSphere intent. |
| 400 | Missing or invalid signature. |
| 503 | The intent carries an OrderSphere `orderId`, but the payment worker has not stored the record yet. Stripe retries later. |

A contradiction that no automatic transition resolves (for example `payment_intent.succeeded` for a
payment recorded as failed) is logged at `Error` as EventId 5001 *Stripe reconciliation required*.
Resolve it in the Stripe dashboard (refund or cancel the intent) and in the order.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,10 @@ public sealed record OrderStatusChangedIntegrationEvent : IntegrationEvent
public required string PreviousStatus { get; init; }
public required string NewStatus { get; init; }
public required string CustomerEmail { get; init; }

/// <summary>
/// Owner of the order. Webhook delivery is scoped to this customer's subscriptions;
/// an event without it (published before the property existed) is delivered to nobody.
/// </summary>
public Guid? CustomerId { get; init; }
}
8 changes: 6 additions & 2 deletions src/Frontend/OrderSphere.Web/Services/LoginRedirect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ public static class LoginRedirect
{
public static string Url(string returnUrl) => $"/bff/login?returnUrl={Uri.EscapeDataString(returnUrl)}";

/// <summary>Full-page navigation to login, returning to the current page afterwards.</summary>
public static void Go(NavigationManager navigation) => navigation.NavigateTo(Url(navigation.Uri), forceLoad: true);
/// <summary>
/// Full-page navigation to login, returning to the current page afterwards. The return
/// target is sent as a local path: the BFF rejects absolute URLs to prevent open redirects.
/// </summary>
public static void Go(NavigationManager navigation) =>
navigation.NavigateTo(Url("/" + navigation.ToBaseRelativePath(navigation.Uri)), forceLoad: true);

/// <summary>True when signed in; otherwise redirects to login and returns false.</summary>
public static async Task<bool> EnsureSignedInAsync(Task<AuthenticationState>? authState, NavigationManager navigation)
Expand Down
16 changes: 16 additions & 0 deletions src/Gateways/OrderSphere.ApiGateway/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@
},
"AuthorizationPolicy": "default"
},
"ordering-admin-coupons": {
"ClusterId": "ordering",
"Match": {
"Path": "/api/v1/admin/coupons/{**catch-all}"
},
"AuthorizationPolicy": "default"
},
"ordering-worker-dlq": {
"ClusterId": "ordering-worker",
"Match": {
Expand Down Expand Up @@ -179,6 +186,15 @@
},
"AuthorizationPolicy": "default"
},
"payment-stripe-webhook": {
"ClusterId": "payment",
"Order": -1,
"Match": {
"Path": "/api/v1/payments/webhooks/stripe",
"Methods": [ "POST" ]
},
"AuthorizationPolicy": "anonymous"
},
"payment": {
"ClusterId": "payment",
"Match": {
Expand Down
21 changes: 21 additions & 0 deletions src/Gateways/OrderSphere.Bff/Auth/LocalReturnUrl.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace OrderSphere.Bff.Auth;

/// <summary>
/// Restricts the post-login redirect of <c>/bff/login</c> to paths on this origin.
/// <c>returnUrl</c> is a query parameter any link can set; accepting an absolute or
/// protocol-relative URL would send the user to a foreign site after a genuine sign-in.
/// </summary>
public static class LocalReturnUrl
{
/// <summary>Returns <paramref name="returnUrl"/> when it is a local path, otherwise <c>/</c>.</summary>
public static string Sanitize(string? returnUrl) => IsLocal(returnUrl) ? returnUrl : "/";

// Same rule as ASP.NET Core's IsLocalUrl: one leading '/', not followed by '/' or '\'
// (browsers treat "//host" and "/\host" as protocol-relative), and no control
// characters (browsers strip e.g. a tab, so "/\t/host" collapses to "//host").
private static bool IsLocal([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] string? url) =>
!string.IsNullOrEmpty(url)
&& url[0] == '/'
&& (url.Length == 1 || (url[1] != '/' && url[1] != '\\'))
&& !url.Any(char.IsControl);
}
9 changes: 3 additions & 6 deletions src/Gateways/OrderSphere.Bff/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,9 @@
app.UseOrderSphereRequestLogging();

app.MapGet("/bff/login", (HttpContext ctx, string? returnUrl) =>
{
var redirect = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl;
return Results.Challenge(
new AuthenticationProperties { RedirectUri = redirect },
[OpenIdConnectDefaults.AuthenticationScheme]);
});
Results.Challenge(
new AuthenticationProperties { RedirectUri = LocalReturnUrl.Sanitize(returnUrl) },
[OpenIdConnectDefaults.AuthenticationScheme]));

app.MapPost("/bff/logout", (HttpContext _) =>
Results.SignOut(
Expand Down
12 changes: 12 additions & 0 deletions src/Gateways/OrderSphere.Bff/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@
},
"AuthorizationPolicy": "anonymous"
},
"stripe-webhook": {
"ClusterId": "api-gateway",
"Order": -1,
"Match": {
"Path": "/webhooks/stripe",
"Methods": [ "POST" ]
},
"AuthorizationPolicy": "anonymous",
"Transforms": [
{ "PathSet": "/api/v1/payments/webhooks/stripe" }
]
},
"to-gateway": {
"ClusterId": "api-gateway",
"Match": {
Expand Down
12 changes: 12 additions & 0 deletions src/Gateways/OrderSphere.Bff/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@
},
"AuthorizationPolicy": "anonymous"
},
"stripe-webhook": {
"ClusterId": "api-gateway",
"Order": -1,
"Match": {
"Path": "/webhooks/stripe",
"Methods": [ "POST" ]
},
"AuthorizationPolicy": "anonymous",
"Transforms": [
{ "PathSet": "/api/v1/payments/webhooks/stripe" }
]
},
"to-gateway": {
"ClusterId": "api-gateway",
"Match": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ public async Task<Result> Handle(CancelOrderCommand request, CancellationToken c
// Paid/Shipped → the reservation was confirmed (on-hand stock decremented); restore it.
var wasConfirmed = order.Status is not OrderStatus.Created;

try { order.Cancel(); }
catch (InvalidOperationException ex)
var cancel = order.Cancel();
if (cancel.IsFailure)
{
logger.LogWarning(ex, "Cannot cancel order {OrderId} in current status", request.OrderId);
return Result.Failure(OrderErrors.InvalidStatusTransition);
logger.LogWarning("Cannot cancel order {OrderId} in status {Status}", request.OrderId, order.Status);
return cancel;
}

if (wasConfirmed)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,18 @@ public async Task<Result> Handle(UpdateOrderStatusCommand request, CancellationT
if (order is null)
return Result.Failure(OrderErrors.OrderNotFoundError);

try
var transition = request.NewStatus switch
{
switch (request.NewStatus)
{
case OrderStatus.Shipped:
order.MarkShipped();
break;
case OrderStatus.Delivered:
order.MarkDelivered();
break;
default:
return Result.Failure(OrderErrors.InvalidStatusTransition);
}
}
catch (InvalidOperationException ex)
OrderStatus.Shipped => order.MarkShipped(),
OrderStatus.Delivered => order.MarkDelivered(),
_ => Result.Failure(OrderErrors.InvalidStatusTransition)
};

if (transition.IsFailure)
{
logger.LogWarning(ex, "Invalid status transition for order {OrderId} to {NewStatus}",
request.OrderId, request.NewStatus);
return Result.Failure(OrderErrors.InvalidStatusTransition);
logger.LogWarning("Invalid status transition for order {OrderId} from {Status} to {NewStatus}",
request.OrderId, order.Status, request.NewStatus);
return transition;
}

await eventStore.AppendAsync(order, cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using OrderSphere.BuildingBlocks.Abstraction;
using OrderSphere.BuildingBlocks.Primitives;
using OrderSphere.BuildingBlocks.StronglyTypedIds;
using OrderSphere.BuildingBlocks.ValueObjects;
using OrderSphere.Ordering.Domain.Enums;
using OrderSphere.Ordering.Domain.Errors;
using OrderSphere.Ordering.Domain.OrderEvents;
using OrderSphere.Ordering.Domain.ValueObjects;

Expand Down Expand Up @@ -93,34 +95,44 @@ public void ApplyDiscount(string couponCode, decimal amount)
public void SetShippingCost(decimal amount)
=> Raise(new ShippingCostSet(amount, DateTime.UtcNow));

public void Confirm(string trackingNumber)
=> Raise(new OrderConfirmed(trackingNumber, DateTime.UtcNow));
// Transitions are guarded here and only here. Apply stays unguarded because it also folds
// persisted streams, which may already contain sequences these guards now reject.

public void MarkShipped()
/// <summary>Marks the order as paid. Only a freshly created order can be confirmed.</summary>
public Result Confirm(string trackingNumber)
{
if (Status is not OrderStatus.Created)
return Result.Failure(OrderErrors.InvalidStatusTransition);

Raise(new OrderConfirmed(trackingNumber, DateTime.UtcNow));
return Result.Success();
}

public Result MarkShipped()
{
if (Status is not OrderStatus.Paid)
throw new InvalidOperationException(
$"Order can only be marked as shipped when status is Paid (current: {Status}).");
return Result.Failure(OrderErrors.InvalidStatusTransition);

Raise(new OrderShipped(DateTime.UtcNow));
return Result.Success();
}

public void MarkDelivered()
public Result MarkDelivered()
{
if (Status is not OrderStatus.Shipped)
throw new InvalidOperationException(
$"Order can only be marked as delivered when status is Shipped (current: {Status}).");
return Result.Failure(OrderErrors.InvalidStatusTransition);

Raise(new OrderDelivered(DateTime.UtcNow));
return Result.Success();
}

public void Cancel()
public Result Cancel()
{
if (Status is OrderStatus.Delivered or OrderStatus.Cancelled)
throw new InvalidOperationException(
$"Order in status {Status} cannot be cancelled.");
return Result.Failure(OrderErrors.InvalidStatusTransition);

Raise(new OrderCancelled(DateTime.UtcNow));
return Result.Success();
}

/// <summary>Clears the uncommitted buffer once the store has persisted the events.</summary>
Expand Down
Loading
Loading