Chapter 13: Integration Patterns

Shopify, Payment Processing, and External API Integration

This chapter provides complete implementation patterns for integrating with Shopify, payment processors (Stripe/Square), and external APIs with PCI-DSS compliance.


13.1 Integration Architecture

┌─────────────────────────────────────────────────────────────────────────────┐
│                          POS Platform                                       │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌─────────────────┐   ┌─────────────────┐   ┌─────────────────────────┐   │
│  │   Shopify       │   │   Payment       │   │   Other Integrations   │   │
│  │   Integration   │   │   Processing    │   │   (Accounting, etc.)   │   │
│  └────────┬────────┘   └────────┬────────┘   └───────────┬─────────────┘   │
│           │                     │                        │                  │
│           ▼                     ▼                        ▼                  │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                    Integration Service Layer                         │   │
│  │  • Webhook handlers       • Payment abstraction                      │   │
│  │  • Retry logic            • Token management                         │   │
│  │  • Event publishing       • Audit logging                            │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
                │                     │                        │
                ▼                     ▼                        ▼
        ┌───────────────┐   ┌───────────────┐        ┌───────────────┐
        │    Shopify    │   │  Stripe API   │        │  QuickBooks   │
        │   Admin API   │   │  Square API   │        │   Online      │
        └───────────────┘   └───────────────┘        └───────────────┘

13.2 Shopify Integration

13.2.1 Webhook Configuration

// File: src/POS.Infrastructure/Integrations/Shopify/ShopifyWebhookConfig.cs
namespace POS.Infrastructure.Integrations.Shopify;

public static class ShopifyWebhookTopics
{
    // Order webhooks
    public const string OrdersCreate = "orders/create";
    public const string OrdersUpdated = "orders/updated";
    public const string OrdersCancelled = "orders/cancelled";
    public const string OrdersFulfilled = "orders/fulfilled";
    public const string OrdersPaid = "orders/paid";

    // Inventory webhooks
    public const string InventoryLevelsUpdate = "inventory_levels/update";
    public const string InventoryLevelsConnect = "inventory_levels/connect";
    public const string InventoryLevelsDisconnect = "inventory_levels/disconnect";

    // Product webhooks
    public const string ProductsCreate = "products/create";
    public const string ProductsUpdate = "products/update";
    public const string ProductsDelete = "products/delete";

    // Customer webhooks
    public const string CustomersCreate = "customers/create";
    public const string CustomersUpdate = "customers/update";

    // Refund webhooks
    public const string RefundsCreate = "refunds/create";
}

13.2.2 Webhook Controller

// File: src/POS.Api/Controllers/ShopifyWebhookController.cs
using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text;

namespace POS.Api.Controllers;

[ApiController]
[Route("api/v1/webhooks/shopify")]
public class ShopifyWebhookController : ControllerBase
{
    private readonly IShopifyWebhookHandler _webhookHandler;
    private readonly IShopifyCredentialService _credentialService;
    private readonly ILogger<ShopifyWebhookController> _logger;

    public ShopifyWebhookController(
        IShopifyWebhookHandler webhookHandler,
        IShopifyCredentialService credentialService,
        ILogger<ShopifyWebhookController> logger)
    {
        _webhookHandler = webhookHandler;
        _credentialService = credentialService;
        _logger = logger;
    }

    [HttpPost("{tenantId}")]
    public async Task<IActionResult> HandleWebhook(
        string tenantId,
        CancellationToken ct)
    {
        // Read raw body for HMAC verification
        Request.EnableBuffering();
        using var reader = new StreamReader(Request.Body, leaveOpen: true);
        var rawBody = await reader.ReadToEndAsync();
        Request.Body.Position = 0;

        // Verify HMAC signature
        var hmacHeader = Request.Headers["X-Shopify-Hmac-Sha256"].FirstOrDefault();
        if (string.IsNullOrEmpty(hmacHeader))
        {
            _logger.LogWarning("Missing HMAC header for tenant {TenantId}", tenantId);
            return Unauthorized();
        }

        var credentials = await _credentialService.GetCredentialsAsync(tenantId, ct);
        if (credentials is null)
        {
            _logger.LogWarning("No Shopify credentials for tenant {TenantId}", tenantId);
            return NotFound();
        }

        if (!VerifyHmac(rawBody, hmacHeader, credentials.WebhookSecret))
        {
            _logger.LogWarning("Invalid HMAC for tenant {TenantId}", tenantId);
            return Unauthorized();
        }

        // Extract webhook topic
        var topic = Request.Headers["X-Shopify-Topic"].FirstOrDefault();
        var shopDomain = Request.Headers["X-Shopify-Shop-Domain"].FirstOrDefault();
        var webhookId = Request.Headers["X-Shopify-Webhook-Id"].FirstOrDefault();

        _logger.LogInformation(
            "Received Shopify webhook {Topic} from {Shop} for tenant {TenantId}",
            topic, shopDomain, tenantId);

        // Queue for processing (respond quickly to Shopify)
        await _webhookHandler.QueueWebhookAsync(new ShopifyWebhookEvent
        {
            TenantId = tenantId,
            Topic = topic!,
            ShopDomain = shopDomain!,
            WebhookId = webhookId!,
            Payload = rawBody,
            ReceivedAt = DateTime.UtcNow
        }, ct);

        return Ok();
    }

    private static bool VerifyHmac(string body, string hmacHeader, string secret)
    {
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
        var computedHmac = Convert.ToBase64String(hash);
        return hmacHeader == computedHmac;
    }
}

13.2.3 Webhook Handler Implementation

// File: src/POS.Infrastructure/Integrations/Shopify/ShopifyWebhookHandler.cs
using System.Text.Json;
using MassTransit;

namespace POS.Infrastructure.Integrations.Shopify;

public class ShopifyWebhookHandler : IShopifyWebhookHandler
{
    private readonly IPublishEndpoint _publishEndpoint;
    private readonly IInventoryService _inventoryService;
    private readonly IOrderService _orderService;
    private readonly IItemService _itemService;
    private readonly ITenantContext _tenantContext;
    private readonly ILogger<ShopifyWebhookHandler> _logger;

    public ShopifyWebhookHandler(
        IPublishEndpoint publishEndpoint,
        IInventoryService inventoryService,
        IOrderService orderService,
        IItemService itemService,
        ITenantContext tenantContext,
        ILogger<ShopifyWebhookHandler> logger)
    {
        _publishEndpoint = publishEndpoint;
        _inventoryService = inventoryService;
        _orderService = orderService;
        _itemService = itemService;
        _tenantContext = tenantContext;
        _logger = logger;
    }

    public async Task QueueWebhookAsync(
        ShopifyWebhookEvent webhook,
        CancellationToken ct)
    {
        // Publish to message queue for async processing
        await _publishEndpoint.Publish(webhook, ct);
    }

    public async Task ProcessWebhookAsync(
        ShopifyWebhookEvent webhook,
        CancellationToken ct)
    {
        _tenantContext.SetTenant(webhook.TenantId);

        try
        {
            switch (webhook.Topic)
            {
                case ShopifyWebhookTopics.OrdersCreate:
                    await HandleOrderCreatedAsync(webhook.Payload, ct);
                    break;

                case ShopifyWebhookTopics.OrdersUpdated:
                    await HandleOrderUpdatedAsync(webhook.Payload, ct);
                    break;

                case ShopifyWebhookTopics.OrdersCancelled:
                    await HandleOrderCancelledAsync(webhook.Payload, ct);
                    break;

                case ShopifyWebhookTopics.InventoryLevelsUpdate:
                    await HandleInventoryUpdateAsync(webhook.Payload, ct);
                    break;

                case ShopifyWebhookTopics.ProductsCreate:
                case ShopifyWebhookTopics.ProductsUpdate:
                    await HandleProductUpdateAsync(webhook.Payload, ct);
                    break;

                case ShopifyWebhookTopics.ProductsDelete:
                    await HandleProductDeleteAsync(webhook.Payload, ct);
                    break;

                default:
                    _logger.LogWarning(
                        "Unhandled webhook topic: {Topic}",
                        webhook.Topic);
                    break;
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex,
                "Error processing webhook {Topic} for tenant {TenantId}",
                webhook.Topic, webhook.TenantId);
            throw;
        }
    }

    private async Task HandleOrderCreatedAsync(string payload, CancellationToken ct)
    {
        var order = JsonSerializer.Deserialize<ShopifyOrder>(payload,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

        if (order is null) return;

        _logger.LogInformation(
            "Processing Shopify order {OrderNumber} ({OrderId})",
            order.OrderNumber, order.Id);

        // Import order to POS system
        var importResult = await _orderService.ImportShopifyOrderAsync(
            new ImportShopifyOrderCommand
            {
                ShopifyOrderId = order.Id.ToString(),
                OrderNumber = order.OrderNumber,
                CustomerEmail = order.Email,
                CustomerName = $"{order.Customer?.FirstName} {order.Customer?.LastName}",
                TotalPrice = order.TotalPrice,
                Currency = order.Currency,
                LineItems = order.LineItems.Select(li => new ImportedLineItem
                {
                    ShopifyLineItemId = li.Id.ToString(),
                    Sku = li.Sku,
                    Title = li.Title,
                    Quantity = li.Quantity,
                    Price = li.Price,
                    VariantId = li.VariantId?.ToString()
                }).ToList(),
                FulfillmentStatus = order.FulfillmentStatus,
                FinancialStatus = order.FinancialStatus,
                ShippingAddress = order.ShippingAddress != null
                    ? new AddressDto
                    {
                        Address1 = order.ShippingAddress.Address1,
                        Address2 = order.ShippingAddress.Address2,
                        City = order.ShippingAddress.City,
                        Province = order.ShippingAddress.Province,
                        Zip = order.ShippingAddress.Zip,
                        Country = order.ShippingAddress.Country
                    }
                    : null,
                CreatedAt = order.CreatedAt
            }, ct);

        if (!importResult.IsSuccess)
        {
            _logger.LogError(
                "Failed to import Shopify order {OrderNumber}: {Error}",
                order.OrderNumber, importResult.Error?.Message);
        }
    }

    private async Task HandleInventoryUpdateAsync(string payload, CancellationToken ct)
    {
        var update = JsonSerializer.Deserialize<ShopifyInventoryLevel>(payload,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

        if (update is null) return;

        _logger.LogInformation(
            "Processing inventory update for variant {InventoryItemId} at location {LocationId}",
            update.InventoryItemId, update.LocationId);

        // Find item by Shopify inventory item ID
        var item = await _itemService.GetByShopifyInventoryItemIdAsync(
            update.InventoryItemId.ToString(), ct);

        if (item is null)
        {
            _logger.LogWarning(
                "Item not found for Shopify inventory item {InventoryItemId}",
                update.InventoryItemId);
            return;
        }

        // Find POS location by Shopify location ID
        var location = await _inventoryService.GetLocationByShopifyIdAsync(
            update.LocationId.ToString(), ct);

        if (location is null)
        {
            _logger.LogWarning(
                "Location not found for Shopify location {LocationId}",
                update.LocationId);
            return;
        }

        // Update inventory (from Shopify, not triggering sync back)
        await _inventoryService.SyncFromShopifyAsync(
            new SyncInventoryCommand
            {
                ItemId = item.Id,
                LocationId = location.Id,
                Quantity = update.Available,
                Source = "shopify_webhook",
                ShopifyUpdatedAt = update.UpdatedAt
            }, ct);
    }

    private async Task HandleProductUpdateAsync(string payload, CancellationToken ct)
    {
        var product = JsonSerializer.Deserialize<ShopifyProduct>(payload,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

        if (product is null) return;

        _logger.LogInformation(
            "Processing product update for {ProductTitle} ({ProductId})",
            product.Title, product.Id);

        foreach (var variant in product.Variants)
        {
            var existingItem = await _itemService.GetByShopifyVariantIdAsync(
                variant.Id.ToString(), ct);

            if (existingItem is not null)
            {
                // Update existing item
                await _itemService.UpdateFromShopifyAsync(
                    existingItem.Id,
                    new UpdateFromShopifyCommand
                    {
                        Name = $"{product.Title} - {variant.Title}",
                        Sku = variant.Sku,
                        Barcode = variant.Barcode,
                        Price = variant.Price,
                        CompareAtPrice = variant.CompareAtPrice,
                        Weight = variant.Weight,
                        WeightUnit = variant.WeightUnit
                    }, ct);
            }
            else
            {
                _logger.LogInformation(
                    "New Shopify variant {VariantId} not linked to POS item",
                    variant.Id);
            }
        }
    }

    private async Task HandleOrderCancelledAsync(string payload, CancellationToken ct)
    {
        var order = JsonSerializer.Deserialize<ShopifyOrder>(payload,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

        if (order is null) return;

        await _orderService.CancelShopifyOrderAsync(order.Id.ToString(), ct);
    }

    private async Task HandleProductDeleteAsync(string payload, CancellationToken ct)
    {
        var deleteEvent = JsonSerializer.Deserialize<ShopifyProductDelete>(payload,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true });

        if (deleteEvent is null) return;

        _logger.LogInformation(
            "Shopify product {ProductId} deleted - marking POS items as inactive",
            deleteEvent.Id);

        await _itemService.DeactivateByShopifyProductIdAsync(
            deleteEvent.Id.ToString(), ct);
    }
}

13.2.4 Shopify API Client

// File: src/POS.Infrastructure/Integrations/Shopify/ShopifyClient.cs
using System.Net.Http.Json;
using System.Text.Json;

namespace POS.Infrastructure.Integrations.Shopify;

public class ShopifyClient : IShopifyClient
{
    private readonly HttpClient _httpClient;
    private readonly IShopifyCredentialService _credentialService;
    private readonly ILogger<ShopifyClient> _logger;

    public ShopifyClient(
        HttpClient httpClient,
        IShopifyCredentialService credentialService,
        ILogger<ShopifyClient> logger)
    {
        _httpClient = httpClient;
        _credentialService = credentialService;
        _logger = logger;
    }

    public async Task<bool> UpdateInventoryLevelAsync(
        string tenantId,
        string inventoryItemId,
        string locationId,
        int quantity,
        CancellationToken ct)
    {
        var credentials = await _credentialService.GetCredentialsAsync(tenantId, ct);
        if (credentials is null)
            throw new InvalidOperationException($"No Shopify credentials for tenant {tenantId}");

        var baseUrl = $"https://{credentials.ShopDomain}/admin/api/2024-01";

        var request = new HttpRequestMessage(HttpMethod.Post,
            $"{baseUrl}/inventory_levels/set.json");

        request.Headers.Add("X-Shopify-Access-Token", credentials.AccessToken);
        request.Content = JsonContent.Create(new
        {
            inventory_item_id = long.Parse(inventoryItemId),
            location_id = long.Parse(locationId),
            available = quantity
        });

        var response = await _httpClient.SendAsync(request, ct);

        if (!response.IsSuccessStatusCode)
        {
            var error = await response.Content.ReadAsStringAsync(ct);
            _logger.LogError(
                "Failed to update Shopify inventory: {StatusCode} - {Error}",
                response.StatusCode, error);
            return false;
        }

        return true;
    }

    public async Task<bool> FulfillOrderAsync(
        string tenantId,
        string orderId,
        string locationId,
        IEnumerable<FulfillmentLineItem> lineItems,
        string? trackingNumber,
        string? trackingCompany,
        CancellationToken ct)
    {
        var credentials = await _credentialService.GetCredentialsAsync(tenantId, ct);
        if (credentials is null)
            throw new InvalidOperationException($"No Shopify credentials for tenant {tenantId}");

        var baseUrl = $"https://{credentials.ShopDomain}/admin/api/2024-01";

        // First, get fulfillment order
        var fulfillmentOrderRequest = new HttpRequestMessage(HttpMethod.Get,
            $"{baseUrl}/orders/{orderId}/fulfillment_orders.json");
        fulfillmentOrderRequest.Headers.Add("X-Shopify-Access-Token", credentials.AccessToken);

        var foResponse = await _httpClient.SendAsync(fulfillmentOrderRequest, ct);
        if (!foResponse.IsSuccessStatusCode)
        {
            _logger.LogError("Failed to get fulfillment orders for order {OrderId}", orderId);
            return false;
        }

        var foResult = await foResponse.Content.ReadFromJsonAsync<FulfillmentOrdersResponse>(ct);
        var fulfillmentOrder = foResult?.FulfillmentOrders?.FirstOrDefault();

        if (fulfillmentOrder is null)
        {
            _logger.LogWarning("No fulfillment order found for order {OrderId}", orderId);
            return false;
        }

        // Create fulfillment
        var fulfillmentRequest = new HttpRequestMessage(HttpMethod.Post,
            $"{baseUrl}/fulfillments.json");
        fulfillmentRequest.Headers.Add("X-Shopify-Access-Token", credentials.AccessToken);

        var fulfillmentPayload = new
        {
            fulfillment = new
            {
                line_items_by_fulfillment_order = new[]
                {
                    new
                    {
                        fulfillment_order_id = fulfillmentOrder.Id,
                        fulfillment_order_line_items = lineItems.Select(li => new
                        {
                            id = li.FulfillmentOrderLineItemId,
                            quantity = li.Quantity
                        }).ToArray()
                    }
                },
                tracking_info = !string.IsNullOrEmpty(trackingNumber) ? new
                {
                    number = trackingNumber,
                    company = trackingCompany
                } : null,
                notify_customer = true
            }
        };

        fulfillmentRequest.Content = JsonContent.Create(fulfillmentPayload);

        var response = await _httpClient.SendAsync(fulfillmentRequest, ct);

        if (!response.IsSuccessStatusCode)
        {
            var error = await response.Content.ReadAsStringAsync(ct);
            _logger.LogError(
                "Failed to create Shopify fulfillment: {StatusCode} - {Error}",
                response.StatusCode, error);
            return false;
        }

        return true;
    }
}

13.3 Payment Processing

13.3.1 PCI-DSS Compliance Pattern

┌─────────────────────────────────────────────────────────────────────────────┐
│                     PCI-DSS Compliant Payment Flow                          │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   1. Card Data NEVER touches POS server                                     │
│   2. Use payment terminal or tokenization                                   │
│   3. Only store payment tokens                                              │
│                                                                             │
│   ┌─────────────┐        ┌─────────────┐        ┌─────────────┐            │
│   │  Customer   │        │  Payment    │        │  Payment    │            │
│   │  Card       │───────►│  Terminal   │───────►│  Processor  │            │
│   └─────────────┘        └─────────────┘        └──────┬──────┘            │
│                                                        │                    │
│                                                        ▼                    │
│   ┌─────────────┐        ┌─────────────┐        ┌─────────────┐            │
│   │    POS      │◄───────│  Token +    │◄───────│  Response   │            │
│   │   Server    │        │  Last 4     │        │  (Success)  │            │
│   └─────────────┘        └─────────────┘        └─────────────┘            │
│                                                                             │
│   Stored: payment_token, card_last_4, card_brand                           │
│   NOT Stored: card_number, cvv, expiry                                     │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

13.3.2 Payment Service Interface

// File: src/POS.Application/Interfaces/IPaymentService.cs
namespace POS.Application.Interfaces;

public interface IPaymentService
{
    Task<Result<PaymentResult>> ProcessPaymentAsync(
        ProcessPaymentCommand command,
        CancellationToken ct = default);

    Task<Result<RefundResult>> ProcessRefundAsync(
        ProcessRefundCommand command,
        CancellationToken ct = default);

    Task<Result> VoidPaymentAsync(
        string transactionId,
        CancellationToken ct = default);

    Task<PaymentMethodsResult> GetAvailableMethodsAsync(
        string locationId,
        CancellationToken ct = default);

    // Terminal operations
    Task<TerminalStatus> GetTerminalStatusAsync(
        string terminalId,
        CancellationToken ct = default);

    Task<Result<TerminalPaymentIntent>> CreateTerminalPaymentIntentAsync(
        CreateTerminalPaymentCommand command,
        CancellationToken ct = default);

    Task<Result<PaymentResult>> CaptureTerminalPaymentAsync(
        string paymentIntentId,
        CancellationToken ct = default);

    Task<Result> CancelTerminalPaymentAsync(
        string paymentIntentId,
        CancellationToken ct = default);
}

13.3.3 Stripe Terminal Integration

// File: src/POS.Infrastructure/Payments/StripePaymentService.cs
using Stripe;
using Stripe.Terminal;

namespace POS.Infrastructure.Payments;

public class StripePaymentService : IPaymentService
{
    private readonly IPaymentCredentialService _credentialService;
    private readonly ITenantContext _tenantContext;
    private readonly IAuditLogger _auditLogger;
    private readonly ILogger<StripePaymentService> _logger;

    public StripePaymentService(
        IPaymentCredentialService credentialService,
        ITenantContext tenantContext,
        IAuditLogger auditLogger,
        ILogger<StripePaymentService> logger)
    {
        _credentialService = credentialService;
        _tenantContext = tenantContext;
        _auditLogger = auditLogger;
        _logger = logger;
    }

    public async Task<Result<PaymentResult>> ProcessPaymentAsync(
        ProcessPaymentCommand command,
        CancellationToken ct = default)
    {
        var credentials = await _credentialService.GetStripeCredentialsAsync(
            _tenantContext.TenantId!, ct);

        if (credentials is null)
            return Result<PaymentResult>.Failure(
                DomainError.PaymentNotConfigured("Stripe"));

        StripeConfiguration.ApiKey = credentials.SecretKey;

        try
        {
            switch (command.Method)
            {
                case PaymentMethod.CreditCard when command.TerminalId is not null:
                    return await ProcessTerminalPaymentAsync(command, ct);

                case PaymentMethod.CreditCard when command.PaymentToken is not null:
                    return await ProcessTokenPaymentAsync(command, ct);

                case PaymentMethod.Cash:
                    return await ProcessCashPaymentAsync(command, ct);

                default:
                    return Result<PaymentResult>.Failure(
                        DomainError.InvalidPaymentMethod(command.Method.ToString()));
            }
        }
        catch (StripeException ex)
        {
            _logger.LogError(ex, "Stripe payment failed: {Code}", ex.StripeError?.Code);

            return Result<PaymentResult>.Failure(
                DomainError.PaymentFailed(ex.StripeError?.Message ?? ex.Message));
        }
    }

    private async Task<Result<PaymentResult>> ProcessTerminalPaymentAsync(
        ProcessPaymentCommand command,
        CancellationToken ct)
    {
        _logger.LogInformation(
            "Processing terminal payment of {Amount} on terminal {TerminalId}",
            command.Amount, command.TerminalId);

        // Create PaymentIntent
        var paymentIntentService = new PaymentIntentService();
        var paymentIntent = await paymentIntentService.CreateAsync(
            new PaymentIntentCreateOptions
            {
                Amount = (long)(command.Amount * 100), // Convert to cents
                Currency = "usd",
                PaymentMethodTypes = new List<string> { "card_present" },
                CaptureMethod = "automatic",
                Metadata = new Dictionary<string, string>
                {
                    ["order_id"] = command.OrderId,
                    ["tenant_id"] = _tenantContext.TenantId!,
                    ["location_id"] = command.LocationId
                }
            }, cancellationToken: ct);

        // Process on terminal
        var readerService = new ReaderService();
        var processResult = await readerService.ProcessPaymentIntentAsync(
            command.TerminalId,
            new ReaderProcessPaymentIntentOptions
            {
                PaymentIntent = paymentIntent.Id
            }, cancellationToken: ct);

        // Wait for payment to complete (simplified - real impl would poll)
        var updatedIntent = await WaitForPaymentCompletionAsync(
            paymentIntent.Id, TimeSpan.FromSeconds(60), ct);

        if (updatedIntent.Status != "succeeded")
        {
            return Result<PaymentResult>.Failure(
                DomainError.PaymentFailed(
                    $"Terminal payment failed with status: {updatedIntent.Status}"));
        }

        var charge = updatedIntent.LatestCharge;

        await _auditLogger.LogAsync(new AuditEvent
        {
            TenantId = _tenantContext.TenantId!,
            EventType = "PaymentProcessed",
            Details = $"Card payment {command.Amount:C} via terminal {command.TerminalId}",
            ReferenceId = paymentIntent.Id,
            ReferenceType = "StripePaymentIntent"
        }, ct);

        return Result<PaymentResult>.Success(new PaymentResult
        {
            Success = true,
            TransactionId = paymentIntent.Id,
            ChargeId = charge?.Id,
            Amount = command.Amount,
            CardLast4 = charge?.PaymentMethodDetails?.CardPresent?.Last4,
            CardBrand = charge?.PaymentMethodDetails?.CardPresent?.Brand,
            AuthorizationCode = charge?.AuthorizationCode
        });
    }

    private async Task<Result<PaymentResult>> ProcessTokenPaymentAsync(
        ProcessPaymentCommand command,
        CancellationToken ct)
    {
        _logger.LogInformation(
            "Processing token payment of {Amount}",
            command.Amount);

        var paymentIntentService = new PaymentIntentService();
        var paymentIntent = await paymentIntentService.CreateAsync(
            new PaymentIntentCreateOptions
            {
                Amount = (long)(command.Amount * 100),
                Currency = "usd",
                PaymentMethod = command.PaymentToken,
                Confirm = true,
                Metadata = new Dictionary<string, string>
                {
                    ["order_id"] = command.OrderId,
                    ["tenant_id"] = _tenantContext.TenantId!
                }
            }, cancellationToken: ct);

        if (paymentIntent.Status != "succeeded")
        {
            return Result<PaymentResult>.Failure(
                DomainError.PaymentFailed(
                    $"Payment failed with status: {paymentIntent.Status}"));
        }

        var charge = paymentIntent.LatestCharge;

        return Result<PaymentResult>.Success(new PaymentResult
        {
            Success = true,
            TransactionId = paymentIntent.Id,
            ChargeId = charge?.Id,
            Amount = command.Amount,
            CardLast4 = charge?.PaymentMethodDetails?.Card?.Last4,
            CardBrand = charge?.PaymentMethodDetails?.Card?.Brand
        });
    }

    private Task<Result<PaymentResult>> ProcessCashPaymentAsync(
        ProcessPaymentCommand command,
        CancellationToken ct)
    {
        // Cash payments don't need external processing
        var transactionId = $"CASH-{Guid.NewGuid():N}"[..24];

        _logger.LogInformation(
            "Recording cash payment of {Amount}",
            command.Amount);

        return Task.FromResult(Result<PaymentResult>.Success(new PaymentResult
        {
            Success = true,
            TransactionId = transactionId,
            Amount = command.Amount
        }));
    }

    public async Task<Result<RefundResult>> ProcessRefundAsync(
        ProcessRefundCommand command,
        CancellationToken ct = default)
    {
        var credentials = await _credentialService.GetStripeCredentialsAsync(
            _tenantContext.TenantId!, ct);

        if (credentials is null)
            return Result<RefundResult>.Failure(
                DomainError.PaymentNotConfigured("Stripe"));

        StripeConfiguration.ApiKey = credentials.SecretKey;

        try
        {
            var refundService = new RefundService();
            var refund = await refundService.CreateAsync(
                new RefundCreateOptions
                {
                    PaymentIntent = command.OriginalTransactionId,
                    Amount = (long)(command.Amount * 100),
                    Reason = MapRefundReason(command.Reason),
                    Metadata = new Dictionary<string, string>
                    {
                        ["refund_order_id"] = command.RefundOrderId,
                        ["original_order_id"] = command.OriginalOrderId
                    }
                }, cancellationToken: ct);

            await _auditLogger.LogAsync(new AuditEvent
            {
                TenantId = _tenantContext.TenantId!,
                EventType = "RefundProcessed",
                Details = $"Refund {command.Amount:C} for order {command.OriginalOrderId}",
                ReferenceId = refund.Id,
                ReferenceType = "StripeRefund"
            }, ct);

            return Result<RefundResult>.Success(new RefundResult
            {
                Success = true,
                TransactionId = refund.Id,
                Amount = command.Amount,
                Status = refund.Status
            });
        }
        catch (StripeException ex)
        {
            _logger.LogError(ex, "Stripe refund failed: {Code}", ex.StripeError?.Code);

            return Result<RefundResult>.Failure(
                DomainError.RefundFailed(ex.StripeError?.Message ?? ex.Message));
        }
    }

    public async Task<Result> VoidPaymentAsync(
        string transactionId,
        CancellationToken ct = default)
    {
        var credentials = await _credentialService.GetStripeCredentialsAsync(
            _tenantContext.TenantId!, ct);

        if (credentials is null)
            return Result.Failure(DomainError.PaymentNotConfigured("Stripe"));

        StripeConfiguration.ApiKey = credentials.SecretKey;

        try
        {
            var paymentIntentService = new PaymentIntentService();
            await paymentIntentService.CancelAsync(transactionId, cancellationToken: ct);

            await _auditLogger.LogAsync(new AuditEvent
            {
                TenantId = _tenantContext.TenantId!,
                EventType = "PaymentVoided",
                ReferenceId = transactionId,
                ReferenceType = "StripePaymentIntent"
            }, ct);

            return Result.Success();
        }
        catch (StripeException ex) when (ex.StripeError?.Code == "payment_intent_unexpected_state")
        {
            // Already captured - need to refund instead
            var refundService = new RefundService();
            await refundService.CreateAsync(
                new RefundCreateOptions { PaymentIntent = transactionId },
                cancellationToken: ct);

            return Result.Success();
        }
        catch (StripeException ex)
        {
            _logger.LogError(ex, "Stripe void failed: {Code}", ex.StripeError?.Code);
            return Result.Failure(
                DomainError.VoidFailed(ex.StripeError?.Message ?? ex.Message));
        }
    }

    private async Task<PaymentIntent> WaitForPaymentCompletionAsync(
        string paymentIntentId,
        TimeSpan timeout,
        CancellationToken ct)
    {
        var paymentIntentService = new PaymentIntentService();
        var startTime = DateTime.UtcNow;

        while (DateTime.UtcNow - startTime < timeout)
        {
            var intent = await paymentIntentService.GetAsync(
                paymentIntentId, cancellationToken: ct);

            if (intent.Status is "succeeded" or "canceled" or "requires_payment_method")
            {
                return intent;
            }

            await Task.Delay(1000, ct);
        }

        throw new TimeoutException("Payment processing timed out");
    }

    private static string MapRefundReason(RefundReason reason) => reason switch
    {
        RefundReason.CustomerRequest => "requested_by_customer",
        RefundReason.Duplicate => "duplicate",
        RefundReason.Fraudulent => "fraudulent",
        _ => "requested_by_customer"
    };
}

13.3.4 Square Integration Pattern

// File: src/POS.Infrastructure/Payments/SquarePaymentService.cs
using Square;
using Square.Models;

namespace POS.Infrastructure.Payments;

public class SquarePaymentService : IPaymentService
{
    private readonly IPaymentCredentialService _credentialService;
    private readonly ITenantContext _tenantContext;
    private readonly ILogger<SquarePaymentService> _logger;

    public SquarePaymentService(
        IPaymentCredentialService credentialService,
        ITenantContext tenantContext,
        ILogger<SquarePaymentService> logger)
    {
        _credentialService = credentialService;
        _tenantContext = tenantContext;
        _logger = logger;
    }

    public async Task<Result<PaymentResult>> ProcessPaymentAsync(
        ProcessPaymentCommand command,
        CancellationToken ct = default)
    {
        var credentials = await _credentialService.GetSquareCredentialsAsync(
            _tenantContext.TenantId!, ct);

        if (credentials is null)
            return Result<PaymentResult>.Failure(
                DomainError.PaymentNotConfigured("Square"));

        var client = new SquareClient.Builder()
            .Environment(credentials.IsSandbox
                ? Square.Environment.Sandbox
                : Square.Environment.Production)
            .AccessToken(credentials.AccessToken)
            .Build();

        try
        {
            // Create terminal checkout for card-present
            if (command.TerminalId is not null)
            {
                var checkoutRequest = new CreateTerminalCheckoutRequest.Builder(
                    Guid.NewGuid().ToString(),
                    new TerminalCheckout.Builder(
                        new Money.Builder()
                            .Amount((long)(command.Amount * 100))
                            .Currency("USD")
                            .Build(),
                        command.TerminalId)
                        .ReferenceId(command.OrderId)
                        .Build())
                    .Build();

                var checkoutResponse = await client.TerminalApi.CreateTerminalCheckoutAsync(
                    checkoutRequest);

                if (checkoutResponse.Errors?.Any() == true)
                {
                    var error = checkoutResponse.Errors.First();
                    return Result<PaymentResult>.Failure(
                        DomainError.PaymentFailed(error.Detail));
                }

                var checkout = checkoutResponse.Checkout;

                // Poll for completion
                var completedCheckout = await WaitForCheckoutCompletionAsync(
                    client, checkout.Id, TimeSpan.FromSeconds(60), ct);

                if (completedCheckout.Status != "COMPLETED")
                {
                    return Result<PaymentResult>.Failure(
                        DomainError.PaymentFailed(
                            $"Checkout failed with status: {completedCheckout.Status}"));
                }

                return Result<PaymentResult>.Success(new PaymentResult
                {
                    Success = true,
                    TransactionId = completedCheckout.PaymentIds?.FirstOrDefault(),
                    Amount = command.Amount,
                    CardLast4 = completedCheckout.CardDetails?.Card?.Last4,
                    CardBrand = completedCheckout.CardDetails?.Card?.CardBrand
                });
            }
            else
            {
                // Token-based payment
                var paymentRequest = new CreatePaymentRequest.Builder(
                    command.PaymentToken!,
                    Guid.NewGuid().ToString())
                    .AmountMoney(new Money.Builder()
                        .Amount((long)(command.Amount * 100))
                        .Currency("USD")
                        .Build())
                    .LocationId(credentials.LocationId)
                    .ReferenceId(command.OrderId)
                    .Build();

                var paymentResponse = await client.PaymentsApi.CreatePaymentAsync(
                    paymentRequest);

                if (paymentResponse.Errors?.Any() == true)
                {
                    var error = paymentResponse.Errors.First();
                    return Result<PaymentResult>.Failure(
                        DomainError.PaymentFailed(error.Detail));
                }

                var payment = paymentResponse.Payment;

                return Result<PaymentResult>.Success(new PaymentResult
                {
                    Success = true,
                    TransactionId = payment.Id,
                    Amount = command.Amount,
                    CardLast4 = payment.CardDetails?.Card?.Last4,
                    CardBrand = payment.CardDetails?.Card?.CardBrand
                });
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Square payment failed");
            return Result<PaymentResult>.Failure(
                DomainError.PaymentFailed(ex.Message));
        }
    }

    private async Task<TerminalCheckout> WaitForCheckoutCompletionAsync(
        SquareClient client,
        string checkoutId,
        TimeSpan timeout,
        CancellationToken ct)
    {
        var startTime = DateTime.UtcNow;

        while (DateTime.UtcNow - startTime < timeout)
        {
            var response = await client.TerminalApi.GetTerminalCheckoutAsync(checkoutId);
            var checkout = response.Checkout;

            if (checkout.Status is "COMPLETED" or "CANCELED")
            {
                return checkout;
            }

            await Task.Delay(1000, ct);
        }

        throw new TimeoutException("Checkout processing timed out");
    }

    // ... other interface methods
}

13.4 External API Patterns

13.4.1 Circuit Breaker Pattern with Polly v8

The Circuit Breaker pattern prevents cascading failures when external services (Shopify, payment processors) become unavailable. Polly v8 introduces a new fluent API with improved resilience pipelines.

Circuit Breaker States

+------------------------------------------------------------------+
|                   CIRCUIT BREAKER STATES                          |
+------------------------------------------------------------------+
|                                                                   |
|     CLOSED                 OPEN                    HALF-OPEN      |
|   (Normal Flow)        (Fail Fast)              (Test Recovery)   |
|                                                                   |
|   ┌─────────┐          ┌─────────┐              ┌─────────┐       |
|   │ Request │          │ Request │              │ Request │       |
|   │ passes  │          │ blocked │              │ limited │       |
|   │ through │          │ (fast   │              │ (test   │       |
|   │         │          │  fail)  │              │  probe) │       |
|   └────┬────┘          └────┬────┘              └────┬────┘       |
|        │                    │                        │            |
|        ▼                    ▼                        ▼            |
|   ┌─────────┐          ┌─────────┐              ┌─────────┐       |
|   │ Track   │          │ Return  │              │ If OK:  │       |
|   │ failures│          │ cached/ │              │ → CLOSED│       |
|   │ If > 5: │          │ fallback│              │ If fail:│       |
|   │ → OPEN  │          │ After   │              │ → OPEN  │       |
|   │         │          │ timeout:│              │         │       |
|   │         │          │→HALF-OPN│              │         │       |
|   └─────────┘          └─────────┘              └─────────┘       |
|                                                                   |
+------------------------------------------------------------------+

Polly v8 Configuration

// File: src/POS.Infrastructure/Http/ResilienceConfiguration.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;
using Polly;
using Polly.CircuitBreaker;
using Polly.Retry;
using Polly.Timeout;

namespace POS.Infrastructure.Http;

public static class ResilienceConfiguration
{
    public static IServiceCollection AddResilientHttpClients(
        this IServiceCollection services)
    {
        // Shopify client with full resilience pipeline
        services.AddHttpClient<IShopifyClient, ShopifyClient>()
            .AddResilienceHandler("shopify", ConfigureShopifyResilience);

        // Payment processor with stricter circuit breaker
        services.AddHttpClient<IStripeClient, StripeClient>()
            .AddResilienceHandler("stripe", ConfigurePaymentResilience);

        services.AddHttpClient<ISquareClient, SquareClient>()
            .AddResilienceHandler("square", ConfigurePaymentResilience);

        return services;
    }

    private static void ConfigureShopifyResilience(ResiliencePipelineBuilder<HttpResponseMessage> builder)
    {
        builder
            // 1. Timeout for individual requests
            .AddTimeout(new TimeoutStrategyOptions
            {
                Timeout = TimeSpan.FromSeconds(10),
                OnTimeout = args =>
                {
                    Log.Warning("Shopify request timed out after {Timeout}s",
                        args.Timeout.TotalSeconds);
                    return default;
                }
            })

            // 2. Retry with exponential backoff
            .AddRetry(new RetryStrategyOptions<HttpResponseMessage>
            {
                ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
                    .Handle<HttpRequestException>()
                    .Handle<TimeoutRejectedException>()
                    .HandleResult(r => r.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
                    .HandleResult(r => (int)r.StatusCode >= 500),

                MaxRetryAttempts = 3,
                Delay = TimeSpan.FromSeconds(1),
                BackoffType = DelayBackoffType.Exponential,
                UseJitter = true, // Prevents thundering herd

                OnRetry = args =>
                {
                    Log.Warning(
                        "Retrying Shopify request. Attempt {Attempt} after {Delay}ms. " +
                        "Status: {StatusCode}",
                        args.AttemptNumber,
                        args.RetryDelay.TotalMilliseconds,
                        args.Outcome.Result?.StatusCode);
                    return default;
                }
            })

            // 3. Circuit Breaker
            .AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
            {
                ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
                    .Handle<HttpRequestException>()
                    .Handle<TimeoutRejectedException>()
                    .HandleResult(r => (int)r.StatusCode >= 500),

                // Open circuit after 5 failures in 30 seconds
                FailureRatio = 0.5,               // 50% failure rate
                SamplingDuration = TimeSpan.FromSeconds(30),
                MinimumThroughput = 5,            // Minimum requests before evaluating

                // Stay open for 30 seconds before testing
                BreakDuration = TimeSpan.FromSeconds(30),

                OnOpened = args =>
                {
                    Log.Error(
                        "Shopify circuit OPENED. Breaking for {Duration}s. " +
                        "Reason: {Exception}",
                        args.BreakDuration.TotalSeconds,
                        args.Outcome.Exception?.Message ?? "Server errors");

                    // Trigger alert
                    AlertService.SendCircuitBreakerAlert("Shopify", "OPEN");
                    return default;
                },

                OnClosed = args =>
                {
                    Log.Information("Shopify circuit CLOSED. Service recovered.");
                    AlertService.SendCircuitBreakerAlert("Shopify", "CLOSED");
                    return default;
                },

                OnHalfOpened = args =>
                {
                    Log.Information("Shopify circuit HALF-OPEN. Testing recovery...");
                    return default;
                }
            });
    }

    private static void ConfigurePaymentResilience(ResiliencePipelineBuilder<HttpResponseMessage> builder)
    {
        builder
            // Shorter timeout for payment operations
            .AddTimeout(new TimeoutStrategyOptions
            {
                Timeout = TimeSpan.FromSeconds(30) // Payment processors need more time
            })

            // Fewer retries for payments (idempotency concerns)
            .AddRetry(new RetryStrategyOptions<HttpResponseMessage>
            {
                ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
                    .Handle<HttpRequestException>()
                    .HandleResult(r => r.StatusCode == System.Net.HttpStatusCode.TooManyRequests),
                    // Do NOT retry 5xx for payments - could cause double charges

                MaxRetryAttempts = 2,
                Delay = TimeSpan.FromSeconds(2),
                BackoffType = DelayBackoffType.Linear
            })

            // Stricter circuit breaker for payments
            .AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
            {
                FailureRatio = 0.3,               // Open at 30% failure rate
                SamplingDuration = TimeSpan.FromSeconds(60),
                MinimumThroughput = 3,
                BreakDuration = TimeSpan.FromMinutes(1),

                OnOpened = args =>
                {
                    Log.Critical(
                        "PAYMENT CIRCUIT OPENED - Switching to fallback processor");
                    AlertService.SendCriticalAlert("Payment", "Circuit breaker opened");
                    return default;
                }
            });
    }
}

Fallback Strategy for Circuit Breaker

// File: src/POS.Infrastructure/Http/FallbackShopifyClient.cs

public class ResilientShopifyClient : IShopifyClient
{
    private readonly HttpClient _httpClient;
    private readonly IShopifyCache _cache;
    private readonly ILogger _logger;
    private readonly ResiliencePipeline<HttpResponseMessage> _pipeline;

    public async Task<ShopifyProduct?> GetProductAsync(
        string tenantId,
        string productId,
        CancellationToken ct)
    {
        try
        {
            var response = await _pipeline.ExecuteAsync(async token =>
            {
                var request = CreateRequest(tenantId, $"/products/{productId}.json");
                return await _httpClient.SendAsync(request, token);
            }, ct);

            if (response.IsSuccessStatusCode)
            {
                var product = await response.Content
                    .ReadFromJsonAsync<ShopifyProductResponse>(ct);

                // Cache successful response for fallback
                await _cache.SetProductAsync(productId, product.Product, ct);
                return product.Product;
            }

            return null;
        }
        catch (BrokenCircuitException)
        {
            // Circuit is open - use cached data as fallback
            _logger.LogWarning(
                "Shopify circuit open. Using cached product {ProductId}",
                productId);

            return await _cache.GetProductAsync(productId, ct);
        }
        catch (TimeoutRejectedException)
        {
            _logger.LogWarning("Shopify request timed out for product {ProductId}", productId);
            return await _cache.GetProductAsync(productId, ct);
        }
    }
}

Circuit Breaker Metrics for Grafana

# prometheus/alerts/circuit-breaker.yml

groups:
  - name: circuit-breaker-alerts
    rules:
      - alert: CircuitBreakerOpen
        expr: polly_circuit_breaker_state{state="open"} == 1
        for: 0m
        labels:
          severity: critical
        annotations:
          summary: "Circuit breaker OPEN for {{ $labels.service }}"
          description: "External service {{ $labels.service }} is failing"
          runbook_url: "https://wiki/runbooks/circuit-breaker"

      - alert: CircuitBreakerHalfOpen
        expr: polly_circuit_breaker_state{state="half_open"} == 1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Circuit breaker stuck in HALF-OPEN for {{ $labels.service }}"

13.4.2 API Rate Limiting

Rate limiting protects both the POS API from abuse and external APIs from being overwhelmed. Implementation uses Token Bucket algorithm for smooth traffic shaping.

Rate Limiting Architecture

+------------------------------------------------------------------+
|                    RATE LIMITING LAYERS                           |
+------------------------------------------------------------------+
|                                                                   |
|  Layer 1: Global Rate Limit (per IP)                              |
|           └── 1000 requests/minute per IP                         |
|                                                                   |
|  Layer 2: Tenant Rate Limit (per API key)                         |
|           └── Based on subscription tier                          |
|           └── Free: 100/min, Pro: 1000/min, Enterprise: 10000/min │
|                                                                   |
|  Layer 3: Endpoint Rate Limit (per route)                         |
|           └── /api/payments: 10/min per tenant (prevent fraud)   │
|           └── /api/reports: 5/min (expensive queries)            │
|                                                                   |
|  Layer 4: Outbound Rate Limit (to external APIs)                  |
|           └── Shopify: 40 requests/second per store              │
|           └── Stripe: 100 requests/second per account            │
|                                                                   |
+------------------------------------------------------------------+

Token Bucket Implementation

// File: src/POS.Infrastructure/RateLimiting/TokenBucketRateLimiter.cs

using System.Threading.RateLimiting;

public static class RateLimitingConfiguration
{
    public static IServiceCollection AddRateLimiting(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        services.AddRateLimiter(options =>
        {
            // Global rate limit by IP
            options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
                httpContext =>
                {
                    var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";

                    return RateLimitPartition.GetTokenBucketLimiter(
                        partitionKey: clientIp,
                        factory: _ => new TokenBucketRateLimiterOptions
                        {
                            TokenLimit = 100,           // Bucket capacity
                            TokensPerPeriod = 100,      // Refill amount
                            ReplenishmentPeriod = TimeSpan.FromMinutes(1),
                            QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                            QueueLimit = 10,            // Queue up to 10 requests
                            AutoReplenishment = true
                        });
                });

            // Tenant-based rate limit (by API key)
            options.AddPolicy("tenant", httpContext =>
            {
                var tenantId = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();
                var tier = GetTenantTier(httpContext, tenantId);

                return RateLimitPartition.GetTokenBucketLimiter(
                    partitionKey: tenantId ?? "anonymous",
                    factory: _ => tier switch
                    {
                        "enterprise" => new TokenBucketRateLimiterOptions
                        {
                            TokenLimit = 10000,
                            TokensPerPeriod = 10000,
                            ReplenishmentPeriod = TimeSpan.FromMinutes(1)
                        },
                        "pro" => new TokenBucketRateLimiterOptions
                        {
                            TokenLimit = 1000,
                            TokensPerPeriod = 1000,
                            ReplenishmentPeriod = TimeSpan.FromMinutes(1)
                        },
                        _ => new TokenBucketRateLimiterOptions
                        {
                            TokenLimit = 100,
                            TokensPerPeriod = 100,
                            ReplenishmentPeriod = TimeSpan.FromMinutes(1)
                        }
                    });
            });

            // Payment endpoint (stricter limit)
            options.AddPolicy("payments", httpContext =>
            {
                var tenantId = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();

                return RateLimitPartition.GetFixedWindowLimiter(
                    partitionKey: tenantId ?? "anonymous",
                    factory: _ => new FixedWindowRateLimiterOptions
                    {
                        PermitLimit = 10,
                        Window = TimeSpan.FromMinutes(1),
                        QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
                        QueueLimit = 2
                    });
            });

            // Reporting endpoint (expensive queries)
            options.AddPolicy("reports", httpContext =>
            {
                var tenantId = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();

                return RateLimitPartition.GetSlidingWindowLimiter(
                    partitionKey: tenantId ?? "anonymous",
                    factory: _ => new SlidingWindowRateLimiterOptions
                    {
                        PermitLimit = 5,
                        Window = TimeSpan.FromMinutes(1),
                        SegmentsPerWindow = 6,  // 10-second segments
                        QueueLimit = 0          // No queuing for reports
                    });
            });

            // Custom rejection response
            options.OnRejected = async (context, token) =>
            {
                context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
                context.HttpContext.Response.Headers.RetryAfter =
                    context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)
                        ? ((int)retryAfter.TotalSeconds).ToString()
                        : "60";

                await context.HttpContext.Response.WriteAsJsonAsync(new
                {
                    error = "rate_limit_exceeded",
                    message = "Too many requests. Please retry after the specified time.",
                    retry_after_seconds = retryAfter?.TotalSeconds ?? 60
                }, token);
            };
        });

        return services;
    }
}

Applying Rate Limits to Controllers

// File: src/POS.Api/Controllers/PaymentsController.cs

[ApiController]
[Route("api/v1/payments")]
[EnableRateLimiting("payments")]  // Apply payments rate limit
public class PaymentsController : ControllerBase
{
    [HttpPost]
    [EnableRateLimiting("payments")]
    public async Task<IActionResult> ProcessPayment(
        [FromBody] PaymentRequest request)
    {
        // Rate limited to 10/minute per tenant
        return Ok(await _paymentService.ProcessAsync(request));
    }
}

[ApiController]
[Route("api/v1/reports")]
[EnableRateLimiting("reports")]
public class ReportsController : ControllerBase
{
    [HttpGet("sales")]
    public async Task<IActionResult> GetSalesReport(
        [FromQuery] DateRange range)
    {
        // Rate limited to 5/minute per tenant
        return Ok(await _reportService.GenerateSalesReport(range));
    }
}

Outbound Rate Limiting for External APIs

// File: src/POS.Infrastructure/Http/OutboundRateLimiter.cs

public class ShopifyRateLimitedClient : IShopifyClient
{
    private readonly HttpClient _httpClient;
    private readonly RateLimiter _rateLimiter;
    private readonly ILogger _logger;

    public ShopifyRateLimitedClient(HttpClient httpClient, ILogger<ShopifyRateLimitedClient> logger)
    {
        _httpClient = httpClient;
        _logger = logger;

        // Shopify allows 40 requests per second per store
        // Use sliding window to smooth out bursts
        _rateLimiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions
        {
            PermitLimit = 40,
            Window = TimeSpan.FromSeconds(1),
            SegmentsPerWindow = 4,  // 250ms segments
            QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
            QueueLimit = 100        // Queue excess requests
        });
    }

    public async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken ct)
    {
        using var lease = await _rateLimiter.AcquireAsync(1, ct);

        if (!lease.IsAcquired)
        {
            _logger.LogWarning("Shopify rate limit exceeded. Request queued.");
            throw new RateLimitExceededException("Shopify API rate limit exceeded");
        }

        var response = await _httpClient.SendAsync(request, ct);

        // Check Shopify's rate limit headers
        if (response.Headers.TryGetValues("X-Shopify-Shop-Api-Call-Limit", out var values))
        {
            var callLimit = values.First();  // e.g., "35/40"
            var parts = callLimit.Split('/');
            var current = int.Parse(parts[0]);
            var max = int.Parse(parts[1]);

            if (current > max * 0.8)  // 80% threshold
            {
                _logger.LogWarning(
                    "Shopify API approaching limit: {Current}/{Max}",
                    current, max);
            }
        }

        return response;
    }
}

Rate Limit Headers in Responses

// File: src/POS.Api/Middleware/RateLimitHeaderMiddleware.cs

public class RateLimitHeaderMiddleware
{
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        await next(context);

        // Add rate limit headers to response
        if (context.Features.Get<IRateLimitFeature>() is { } feature)
        {
            context.Response.Headers["X-RateLimit-Limit"] = feature.Limit.ToString();
            context.Response.Headers["X-RateLimit-Remaining"] = feature.Remaining.ToString();
            context.Response.Headers["X-RateLimit-Reset"] = feature.Reset.ToUnixTimeSeconds().ToString();
        }
    }
}

// Response example:
// HTTP/1.1 200 OK
// X-RateLimit-Limit: 100
// X-RateLimit-Remaining: 87
// X-RateLimit-Reset: 1706140800

13.4.3 Basic Retry Configuration (Legacy Reference)

For simpler scenarios without full Polly v8 pipeline:

// File: src/POS.Infrastructure/Http/HttpClientConfiguration.cs
using Microsoft.Extensions.DependencyInjection;
using Polly;
using Polly.Extensions.Http;

namespace POS.Infrastructure.Http;

public static class HttpClientConfiguration
{
    public static IServiceCollection AddExternalApiClients(
        this IServiceCollection services)
    {
        // Shopify client with retry
        services.AddHttpClient<IShopifyClient, ShopifyClient>()
            .AddPolicyHandler(GetRetryPolicy())
            .AddPolicyHandler(GetCircuitBreakerPolicy());

        // Payment clients
        services.AddHttpClient<IStripeClient, StripeClient>()
            .AddPolicyHandler(GetRetryPolicy());

        services.AddHttpClient<ISquareClient, SquareClient>()
            .AddPolicyHandler(GetRetryPolicy());

        return services;
    }

    private static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
    {
        return HttpPolicyExtensions
            .HandleTransientHttpError()
            .OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
            .WaitAndRetryAsync(3, retryAttempt =>
                TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
    }

    private static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy()
    {
        return HttpPolicyExtensions
            .HandleTransientHttpError()
            .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));
    }
}

13.4.4 Credential Management

// File: src/POS.Infrastructure/Security/CredentialService.cs
using Microsoft.Extensions.Caching.Memory;
using Azure.Security.KeyVault.Secrets;

namespace POS.Infrastructure.Security;

public class CredentialService : IPaymentCredentialService, IShopifyCredentialService
{
    private readonly IIntegrationCredentialRepository _repository;
    private readonly SecretClient? _keyVaultClient;
    private readonly IMemoryCache _cache;
    private readonly ILogger<CredentialService> _logger;

    public async Task<StripeCredentials?> GetStripeCredentialsAsync(
        string tenantId,
        CancellationToken ct)
    {
        var cacheKey = $"stripe:{tenantId}";

        if (_cache.TryGetValue(cacheKey, out StripeCredentials? cached))
            return cached;

        var integration = await _repository.GetByTypeAsync(
            tenantId, IntegrationType.Stripe, ct);

        if (integration is null)
            return null;

        // Decrypt secret key from Key Vault or encrypted storage
        var secretKey = _keyVaultClient is not null
            ? (await _keyVaultClient.GetSecretAsync(
                $"stripe-{tenantId}", cancellationToken: ct)).Value.Value
            : DecryptSecret(integration.EncryptedSecretKey);

        var credentials = new StripeCredentials
        {
            PublishableKey = integration.PublicKey,
            SecretKey = secretKey,
            WebhookSecret = integration.WebhookSecret
        };

        _cache.Set(cacheKey, credentials, TimeSpan.FromMinutes(15));

        return credentials;
    }

    public async Task<ShopifyCredentials?> GetCredentialsAsync(
        string tenantId,
        CancellationToken ct)
    {
        var cacheKey = $"shopify:{tenantId}";

        if (_cache.TryGetValue(cacheKey, out ShopifyCredentials? cached))
            return cached;

        var integration = await _repository.GetByTypeAsync(
            tenantId, IntegrationType.Shopify, ct);

        if (integration is null)
            return null;

        var accessToken = _keyVaultClient is not null
            ? (await _keyVaultClient.GetSecretAsync(
                $"shopify-{tenantId}", cancellationToken: ct)).Value.Value
            : DecryptSecret(integration.EncryptedSecretKey);

        var credentials = new ShopifyCredentials
        {
            ShopDomain = integration.ExternalId,
            AccessToken = accessToken,
            WebhookSecret = integration.WebhookSecret
        };

        _cache.Set(cacheKey, credentials, TimeSpan.FromMinutes(15));

        return credentials;
    }

    private static string DecryptSecret(string encryptedValue)
    {
        // Implementation depends on encryption strategy
        // Could use DPAPI, AES, etc.
        throw new NotImplementedException(
            "Implement based on your encryption strategy");
    }
}

13.5 Integration Error Codes (ERR-6xxx)

All integration-related errors use the ERR-6xxx range per the BRD error code convention. See Chapter 05 (Architecture Components) for the full Module 6: Integrations & External Systems architecture, including Amazon SP-API, Google Merchant API, and enhanced Shopify patterns.

Code RangeDomainDescription
ERR-6001–6009GeneralCross-integration errors (auth failures, timeout, config missing)
ERR-6010–6029ShopifyWebhook verification, product sync, inventory sync, order import
ERR-6030–6049Amazon SP-APIFeed submission, listing sync, order pull, inventory push
ERR-6050–6069Google MerchantProduct feed, price update, availability sync
ERR-6070–6089Payment ProcessorsStripe/Square terminal errors, batch settlement, refund failures
ERR-6090–6099Email/ShippingNotification delivery, label generation, tracking sync

Common Integration Error Constants

// File: src/POS.Domain/Errors/IntegrationErrors.cs
namespace POS.Domain.Errors;

public static class IntegrationErrors
{
    // General (ERR-6001–6009)
    public const string AuthFailed = "ERR-6001";          // OAuth/API key authentication failed
    public const string Timeout = "ERR-6002";              // External API call timed out
    public const string CircuitOpen = "ERR-6003";          // Circuit breaker is open
    public const string ConfigMissing = "ERR-6004";        // Integration not configured for tenant
    public const string RateLimited = "ERR-6005";          // External API rate limit exceeded
    public const string MappingFailed = "ERR-6006";        // Data mapping/transform error
    public const string WebhookVerifyFailed = "ERR-6007";  // Webhook signature verification failed
    public const string DuplicateSync = "ERR-6008";        // Idempotency check — already synced
    public const string ChannelDisabled = "ERR-6009";      // Sales channel disabled for tenant

    // Shopify (ERR-6010–6029)
    public const string ShopifyWebhookInvalid = "ERR-6010";
    public const string ShopifyProductSyncFailed = "ERR-6011";
    public const string ShopifyInventorySyncFailed = "ERR-6012";
    public const string ShopifyOrderImportFailed = "ERR-6013";
    public const string ShopifyFulfillmentFailed = "ERR-6014";
    public const string ShopifyGraphQLError = "ERR-6015";

    // Amazon SP-API (ERR-6030–6049)
    public const string AmazonFeedFailed = "ERR-6030";
    public const string AmazonListingSyncFailed = "ERR-6031";
    public const string AmazonOrderPullFailed = "ERR-6032";
    public const string AmazonInventoryPushFailed = "ERR-6033";

    // Google Merchant (ERR-6050–6069)
    public const string GoogleProductFeedFailed = "ERR-6050";
    public const string GooglePriceUpdateFailed = "ERR-6051";
    public const string GoogleAvailabilitySyncFailed = "ERR-6052";

    // Payment Processors (ERR-6070–6089)
    public const string StripeTerminalError = "ERR-6070";
    public const string StripeSettlementFailed = "ERR-6071";
    public const string SquareTerminalError = "ERR-6075";
    public const string SquareSettlementFailed = "ERR-6076";
    public const string PaymentRefundFailed = "ERR-6080";
    public const string BatchCloseFailed = "ERR-6081";
}

Summary

This chapter covered complete integration patterns:

  • Shopify Integration: Webhooks for orders, inventory, and products with HMAC verification
  • Payment Processing: PCI-DSS compliant patterns with Stripe Terminal and Square
  • Token-only storage: Never store card numbers, only payment tokens
  • External API resilience: Retry policies and circuit breakers with Polly
  • Credential management: Secure storage with caching
  • Integration error codes: ERR-6xxx range covering all external system failures

See also: Chapter 05 (Architecture Components) defines the complete Module 6: Integrations & External Systems architecture with Amazon SP-API, Google Merchant API, enhanced Shopify integration, and the strictest-rule-wins cross-platform validation strategy.

Next: Part V: Frontend - Chapter 14: POS Client covers frontend implementation with the POS Client application.


Document Information

AttributeValue
Version5.0.0
Created2025-12-29
Updated2026-02-25
AuthorClaude Code
StatusActive
PartIV - Backend
Chapter13 of 32

This chapter is part of the POS Blueprint Book. All content is self-contained.