Skip to content

Payment Processing Service

Verified by tests

ProcessPaymentReceptorTests — library CI run #31657041675 (2026-08-13)

Build the Payment Worker - a background service that handles ProcessPaymentCommand, simulates a payment gateway, and publishes PaymentProcessedEvent on success or PaymentFailedEvent for compensation.

:::note This is Part 3 of the ECommerce Tutorial. Complete Inventory Service first. :::


What You'll Build

flowchart TD
    subgraph PSA["Payment Service Architecture"]
        ASB["Azure Service Bus"]
        Inbox["wh_inbox<br/>(Exactly-Once)"]
        Receptor["ProcessPaymentReceptor<br/>- Call gateway<br/>- Publish success/failure"]
        EventStore["wh_event_store (Event Store)"]
        OutboxTable["wh_outbox (Outbox)"]
        Gateway["Payment Gateway API"]
        Processed["PaymentProcessedEvent"]
        Failed["PaymentFailedEvent"]

        ASB -->|"ProcessPaymentCommand"| Inbox
        Inbox --> Receptor
        Receptor --> Gateway
        Receptor --> EventStore
        Receptor --> OutboxTable
        OutboxTable --> Processed
        OutboxTable --> Failed
    end

    class ASB,OutboxTable layer-command
    class Inbox,Receptor layer-core
    class EventStore,Processed,Failed layer-event
    class Gateway layer-infrastructure

Features: - ✅ Payment success/failure branching - ✅ Compensation via PaymentFailedEvent - ✅ Framework-managed inbox/outbox/event store - ✅ Production-hardening patterns (gateway abstraction, retry, circuit breaker)


Step 1: Define Messages

ProcessPaymentCommand

ECommerce.Contracts/Commands/ProcessPaymentCommand.cs:

ProcessPaymentCommand

using Whizbang.Core;

namespace ECommerce.Contracts.Commands;

/// <summary>
/// Command to process payment for an order after inventory is reserved
/// </summary>
public record ProcessPaymentCommand : ICommand {
  public required string OrderId { get; init; }
  public required string CustomerId { get; init; }
  public decimal Amount { get; init; }
}

PaymentProcessedEvent

ECommerce.Contracts/Events/PaymentProcessedEvent.cs:

PaymentProcessed Event

using Whizbang.Core;

namespace ECommerce.Contracts.Events;

/// <summary>
/// Event published when payment is successfully processed
/// </summary>
public record PaymentProcessedEvent : IEvent {
  [StreamId]
  public required string OrderId { get; init; }
  public required string CustomerId { get; init; }
  public decimal Amount { get; init; }
  public required string TransactionId { get; init; }
}

PaymentFailedEvent (Compensation)

ECommerce.Contracts/Events/PaymentFailedEvent.cs:

PaymentFailed Event (Compensation)

using Whizbang.Core;

namespace ECommerce.Contracts.Events;

/// <summary>
/// Event published when payment processing fails
/// </summary>
public record PaymentFailedEvent : IEvent {
  [StreamId]
  public required string OrderId { get; init; }
  public required string CustomerId { get; init; }
  public required string Reason { get; init; }
}

Step 2: Persistence (Framework-Managed)

Updated

Earlier drafts hand-wrote a payments table plus outbox SQL. The sample's PaymentDbContext uses the [WhizbangDbContext] attribute — wh_inbox, wh_outbox, and wh_event_store are created by EnsureWhizbangDatabaseInitializedAsync(). Payment history lives in the event stream; add a perspective if you need a queryable payments read model.


Step 3: Implement Receptor

ECommerce.PaymentWorker/Receptors/ProcessPaymentReceptor.cs:

Step 3: Implement Receptor

using ECommerce.Contracts.Commands;
using ECommerce.Contracts.Events;
using Microsoft.Extensions.Logging;
using Whizbang.Core;

namespace ECommerce.PaymentWorker.Receptors;

/// <summary>
/// Handles ProcessPaymentCommand and publishes PaymentProcessedEvent or PaymentFailedEvent
/// </summary>
public class ProcessPaymentReceptor(IDispatcher dispatcher, ILogger<ProcessPaymentReceptor> logger) : IReceptor<ProcessPaymentCommand, PaymentProcessedEvent> {

  public async ValueTask<PaymentProcessedEvent> HandleAsync(
    ProcessPaymentCommand message,
    CancellationToken cancellationToken = default) {

    logger.LogInformation(
      "Processing payment of ${Amount} for customer {CustomerId} and order {OrderId}",
      message.Amount,
      message.CustomerId,
      message.OrderId);

    // Simulate payment processing logic
    // In a real system, this would call a payment gateway API
    var random = new Random();
    var shouldSucceed = random.Next(100) < 90; // 90% success rate for demo

    if (shouldSucceed) {
      // Payment successful
      var paymentProcessed = new PaymentProcessedEvent {
        OrderId = message.OrderId,
        CustomerId = message.CustomerId,
        Amount = message.Amount,
        TransactionId = $"TXN-{Guid.NewGuid():N}"
      };

      // Publish success event
      await dispatcher.PublishAsync(paymentProcessed);

      logger.LogInformation(
        "Payment processed successfully for order {OrderId} with transaction {TransactionId}",
        message.OrderId,
        paymentProcessed.TransactionId);

      return paymentProcessed;
    } else {
      // Payment failed
      var paymentFailed = new PaymentFailedEvent {
        OrderId = message.OrderId,
        CustomerId = message.CustomerId,
        Reason = "Insufficient funds"
      };

      // Publish failure event
      await dispatcher.PublishAsync(paymentFailed);

      logger.LogWarning(
        "Payment failed for order {OrderId}: {Reason}",
        message.OrderId,
        paymentFailed.Reason);

      // We still need to return a PaymentProcessedEvent to satisfy the interface
      // In a real system, you might use a Result<T> type or throw an exception
      throw new InvalidOperationException($"Payment failed: {paymentFailed.Reason}");
    }
  }
}

Key patterns: - ✅ Success/failure branching: publish PaymentProcessedEvent OR PaymentFailedEvent - ✅ Compensation trigger: PaymentFailedEvent drives inventory release downstream - ✅ TransactionId is a gateway reference string — Whizbang message/stream ids are generated by the framework (UUIDv7); don't hand-roll them - ✅ Exception on failure: the failure event is still published via the outbox before the throw surfaces the failure to the dispatcher


Step 4: Production Hardening (Optional)

The sample simulates the gateway. In production, put the gateway behind an abstraction and wrap calls with retry + circuit breaker policies. These are standard .NET patterns (Polly) — Whizbang doesn't dictate them.

Gateway abstraction:

Step 4: Payment Gateway Abstraction

namespace ECommerce.PaymentWorker.Services;

public interface IPaymentGateway {
  Task<PaymentResult> ChargeAsync(
    string idempotencyKey,
    decimal amount,
    string currency,
    string paymentMethod,
    CancellationToken ct = default
  );

  Task<RefundResult> RefundAsync(
    string transactionId,
    decimal amount,
    CancellationToken ct = default
  );
}

public record PaymentResult(
  bool Success,
  string? TransactionId,
  string? ErrorCode,
  string? ErrorMessage
);

public record RefundResult(
  bool Success,
  string? RefundId,
  string? ErrorMessage
);

Retry + circuit breaker (Polly):

Retry Logic with Polly

// Exponential backoff: 2s, 4s, 8s
var retryPolicy = Policy
  .Handle<HttpRequestException>()
  .WaitAndRetryAsync(
    retryCount: 3,
    sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt))
  );

// Open circuit after 5 failures, half-open after 30s
var circuitBreaker = Policy
  .Handle<HttpRequestException>()
  .CircuitBreakerAsync(
    exceptionsAllowedBeforeBreaking: 5,
    durationOfBreak: TimeSpan.FromSeconds(30)
  );

var result = await circuitBreaker.ExecuteAsync(() =>
  retryPolicy.ExecuteAsync(() =>
    gateway.ChargeAsync(idempotencyKey, amount, "usd", paymentMethod, ct)
  )
);

When to retry: - ✅ Network errors (transient) - ✅ Gateway timeouts (transient) - ❌ Invalid card (permanent) - ❌ Insufficient funds (permanent)

Idempotency: pass a stable idempotency key (e.g., derived from OrderId) to the gateway so redelivered commands can't double-charge. Whizbang's wh_inbox already dedupes redelivered messages before your receptor runs — the gateway key is defense-in-depth for the external call.


Step 5: Service Configuration

ECommerce.PaymentWorker/Program.cs (condensed from the sample):

Step 5: Service Configuration

using Whizbang.Core;
using Whizbang.Core.Generated;
using Whizbang.Data.EFCore.Postgres;
using Whizbang.Transports.AzureServiceBus;
using ECommerce.Contracts.Generated;
using ECommerce.PaymentWorker;
using ECommerce.PaymentWorker.Generated;

var builder = Host.CreateApplicationBuilder(args);

builder.AddServiceDefaults();

var serviceBusConnection = builder.Configuration.GetConnectionString("servicebus")
    ?? throw new InvalidOperationException("Azure Service Bus connection string 'servicebus' not found");

builder.Services.AddAzureServiceBusTransport(serviceBusConnection);
builder.Services.AddAzureServiceBusHealthChecks();

// Unified Whizbang API: routing + EF Core Postgres driver + transport consumer
_ = builder.Services
  .AddWhizbang()
  .WithRouting(routing => {
    routing
      .OwnDomains("ecommerce.payment.commands")
      .SubscribeTo("ecommerce.orders.events")
      .Inbox.UseSharedTopic("inbox");
  })
  .WithEFCore<PaymentDbContext>()
  .WithDriver.Postgres
  .AddTransportConsumer();

builder.Services.AddReceptors();
builder.Services.AddWhizbangDispatcher();

var host = builder.Build();

using (var scope = host.Services.CreateScope()) {
  var dbContext = scope.ServiceProvider.GetRequiredService<PaymentDbContext>();
  var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
  await dbContext.EnsureWhizbangDatabaseInitializedAsync(logger);
}

host.Run();

Step 6: Test the Flow

1. Update Aspire Configuration

ECommerce.AppHost/Program.cs (excerpt matching the sample):

Update Aspire Configuration

var paymentDb = postgres.AddDatabase("paymentdb");

ordersTopic.AddServiceBusSubscription("sub-payment-orders");
inboxTopic.AddServiceBusSubscription("sub-inbox-payment").WithDestinationFilter("payment-service");

var paymentWorker = builder.AddProject("paymentworker", "../ECommerce.PaymentWorker/ECommerce.PaymentWorker.csproj")
    .WithReference(paymentDb)
    .WithReference(messagingInfra)
    .WaitFor(paymentDb)
    .WaitFor(messagingInfra);

2. Create Order (Full Flow)

Create Order (Full Flow)

curl -X POST http://localhost:5000/api/orders \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "0195b3f0-1234-7abc-8def-0123456789ab",
    "lineItems": [
      { "productId": "0195b3f0-5678-7abc-8def-0123456789ab", "productName": "Widget", "quantity": 2, "unitPrice": 19.99 }
    ]
  }'

3. Observe Distributed Transaction

Aspire Dashboard shows: 1. Order Service: OrderCreatedEvent published 2. Inventory Worker: InventoryReservedEvent published 3. Payment Worker: ProcessPaymentCommand handled 4. Payment Worker: PaymentProcessedEvent (or PaymentFailedEvent) published

4. Verify Payment Events

Verify Payment

SELECT stream_id, event_type, created_at
FROM wh_event_store
WHERE event_type LIKE '%Payment%'
ORDER BY created_at DESC;

Key Concepts

Saga Pattern - Distributed Transactions

flowchart TD
    subgraph HappyPath["Saga: Order Processing (Happy Path)"]
        H1["CreateOrderCommand"] --> H2["OrderCreatedEvent"]
        H2 --> H3["ReserveInventoryCommand"] --> H4["InventoryReservedEvent"]
        H4 --> H5["ProcessPaymentCommand"] --> H6["PaymentProcessedEvent"]
        H6 --> H7["CreateShipmentCommand"] --> H8["ShipmentCreatedEvent"]
        H8 --> H9["SendNotificationCommand"] --> H10["NotificationSentEvent"]
    end

    class H1,H3,H5,H7,H9 layer-command
    class H2,H4,H6,H8,H10 layer-event
flowchart TD
    subgraph Compensation["Saga: Payment Failure (Compensation)"]
        C1["CreateOrderCommand"] --> C2["OrderCreatedEvent"]
        C2 --> C3["ReserveInventoryCommand"] --> C4["InventoryReservedEvent"]
        C4 --> C5["ProcessPaymentCommand"] --> C6["PaymentFailedEvent"]
        C6 --> C7["ReleaseInventoryCommand"] --> C8["InventoryReleasedEvent"]
    end

    class C1,C3,C5,C7 layer-command
    class C2,C4,C6,C8 layer-event

Compensating transactions: - PaymentFailedEvent → release inventory (return stock to available) - Order cancellation → refund payment via the gateway

Circuit Breaker States

  • Closed: Normal operation
  • Open: Gateway unavailable, fail fast
  • Half-Open: Test if gateway recovered

Testing

The real tests live in tests/ECommerce.PaymentWorker.Tests/ProcessPaymentReceptorTests.cs. Because the demo receptor is randomized, the tests branch on the outcome:

Unit Test - Payment Receptor

[Test]
public async Task HandleAsync_ProcessesPayment_PublishesEventAsync() {
  // Arrange
  var dispatcher = new TestDispatcher();
  var receptor = new ProcessPaymentReceptor(dispatcher, NullLogger<ProcessPaymentReceptor>.Instance);

  var command = new ProcessPaymentCommand {
    OrderId = "order-123",
    CustomerId = "customer-456",
    Amount = 99.99m
  };

  try {
    // Act
    var result = await receptor.HandleAsync(command);

    // Assert (success path - ~90%)
    await Assert.That(result.OrderId).IsEqualTo("order-123");
    await Assert.That(result.TransactionId).StartsWith("TXN-");
    await Assert.That(dispatcher.PublishedEvents).Count().IsEqualTo(1);
  } catch (InvalidOperationException) {
    // Failure path (~10%): PaymentFailedEvent was published before the throw
    await Assert.That(dispatcher.PublishedEvents[0]).IsAssignableTo<PaymentFailedEvent>();
  }
}

In your own service, inject a deterministic IPaymentGateway fake instead of relying on randomness — then both branches become directly testable.


Next Steps

Continue to Notification Service to: - Handle SendNotificationCommand - Publish NotificationSentEvent - Integrate with email/SMS providers


Key Takeaways

Success/Failure Events - PaymentProcessedEvent vs PaymentFailedEventCompensation - Failure events trigger inventory release ✅ Framework Dedup - wh_inbox prevents double-processing; gateway idempotency keys add defense-in-depth ✅ Retry Logic - Exponential backoff for transient failures (Polly) ✅ Circuit Breaker - Fail fast when the gateway is down ✅ Gateway Abstraction - Swap payment providers easily


Version 1.0.0 - Foundation Release | Last Updated: 2026-07-16