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/{credentials.ApiVersion}";

        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/{credentials.ApiVersion}";

        // 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 60 seconds
                FailureRatio = 0.5,               // 50% failure rate
                SamplingDuration = TimeSpan.FromSeconds(60),
                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(60));
    }
}

13.4.4 Credential Management (HashiCorp Vault)

Integration secrets (API keys, OAuth tokens, webhook secrets) are stored in HashiCorp Vault, not in the application database. This provides centralized secret rotation, audit logging, and dynamic credential generation.

// File: src/POS.Infrastructure/Security/CredentialService.cs
using Microsoft.Extensions.Caching.Memory;
using VaultSharp;
using VaultSharp.V1.AuthMethods.Token;

namespace POS.Infrastructure.Security;

public class CredentialService : IPaymentCredentialService, IShopifyCredentialService
{
    private readonly IIntegrationCredentialRepository _repository;
    private readonly IVaultClient _vaultClient;
    private readonly IMemoryCache _cache;
    private readonly ILogger<CredentialService> _logger;

    public CredentialService(
        IIntegrationCredentialRepository repository,
        IVaultClient vaultClient,
        IMemoryCache cache,
        ILogger<CredentialService> logger)
    {
        _repository = repository;
        _vaultClient = vaultClient;
        _cache = cache;
        _logger = 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;

        // Retrieve secret key from HashiCorp Vault
        var secret = await _vaultClient.V1.Secrets.KeyValue.V2.ReadSecretAsync(
            path: $"integrations/{tenantId}/stripe",
            mountPoint: "secret");

        var secretKey = secret.Data.Data["secret_key"]?.ToString()
            ?? throw new InvalidOperationException(
                $"Stripe secret_key not found in Vault for tenant {tenantId}");

        var credentials = new StripeCredentials
        {
            PublishableKey = integration.PublicKey,
            SecretKey = secretKey,
            WebhookSecret = secret.Data.Data["webhook_secret"]?.ToString()
                ?? 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;

        // Retrieve access token from HashiCorp Vault
        var secret = await _vaultClient.V1.Secrets.KeyValue.V2.ReadSecretAsync(
            path: $"integrations/{tenantId}/shopify",
            mountPoint: "secret");

        var accessToken = secret.Data.Data["access_token"]?.ToString()
            ?? throw new InvalidOperationException(
                $"Shopify access_token not found in Vault for tenant {tenantId}");

        var credentials = new ShopifyCredentials
        {
            ShopDomain = integration.ExternalId,
            AccessToken = accessToken,
            WebhookSecret = secret.Data.Data["webhook_secret"]?.ToString()
                ?? integration.WebhookSecret
        };

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

        return credentials;
    }
}

// File: src/POS.Api/Program.cs (Vault registration)
// HashiCorp Vault configuration
var vaultUri = builder.Configuration["Vault:Uri"]
    ?? "https://vault.pos-platform.com:8200";
var vaultToken = builder.Configuration["Vault:Token"];

var authMethod = new TokenAuthMethodInfo(vaultToken);
var vaultClientSettings = new VaultClientSettings(vaultUri, authMethod);
builder.Services.AddSingleton<IVaultClient>(new VaultClient(vaultClientSettings));

13.5 Anti-Corruption Layer (ACL)

External systems (Shopify, Amazon, Google Merchant, QuickBooks) each have their own data models, naming conventions, and quirks. The Anti-Corruption Layer (ACL) translates between external models and our internal domain models, preventing external concepts from leaking into core business logic.

Integration Provider Interface

// File: src/POS.Application/Integrations/IIntegrationProvider.cs
namespace POS.Application.Integrations;

/// <summary>
/// Common interface for all external integration providers.
/// Each provider implements this for its specific platform.
/// </summary>
public interface IIntegrationProvider
{
    string ProviderName { get; }  // "shopify", "amazon", "google_merchant", "quickbooks"

    Task<Result> SyncProductAsync(
        ProductSyncCommand command, CancellationToken ct = default);

    Task<Result> SyncInventoryAsync(
        InventorySyncCommand command, CancellationToken ct = default);

    Task<Result> ImportOrderAsync(
        OrderImportCommand command, CancellationToken ct = default);

    Task<Result<IntegrationHealthStatus>> HealthCheckAsync(
        CancellationToken ct = default);
}

Per-Provider ACL Classes

Each ACL class maps between external platform models and internal domain models. No external model types appear outside the ACL.

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

/// <summary>
/// Maps between Shopify API models and POS domain models.
/// No Shopify-specific types leak beyond this class.
/// </summary>
public class ShopifyAcl
{
    public ItemDto MapToItem(ShopifyProduct product, ShopifyVariant variant)
    {
        return new ItemDto
        {
            Sku = variant.Sku ?? $"SHOP-{variant.Id}",
            Barcode = variant.Barcode,
            Name = variant.Title == "Default Title"
                ? product.Title
                : $"{product.Title} - {variant.Title}",
            Price = variant.Price,
            Cost = variant.InventoryItem?.Cost ?? 0m,
            Weight = variant.Weight,
            WeightUnit = MapWeightUnit(variant.WeightUnit),
            IsActive = product.Status == "active",
            ExternalIds = new Dictionary<string, string>
            {
                ["shopify_product_id"] = product.Id.ToString(),
                ["shopify_variant_id"] = variant.Id.ToString(),
                ["shopify_inventory_item_id"] = variant.InventoryItemId.ToString()
            }
        };
    }

    public ShopifyInventorySetRequest MapToShopifyInventory(
        InventorySyncCommand command, string shopifyLocationId)
    {
        return new ShopifyInventorySetRequest
        {
            InventoryItemId = long.Parse(command.ExternalItemId),
            LocationId = long.Parse(shopifyLocationId),
            Available = command.Quantity
        };
    }

    public OrderDto MapToOrder(ShopifyOrder shopifyOrder)
    {
        return new OrderDto
        {
            ExternalOrderId = shopifyOrder.Id.ToString(),
            OrderNumber = shopifyOrder.OrderNumber.ToString(),
            CustomerEmail = shopifyOrder.Email,
            CustomerName = FormatCustomerName(shopifyOrder.Customer),
            Subtotal = shopifyOrder.SubtotalPrice,
            TaxTotal = shopifyOrder.TotalTax,
            GrandTotal = shopifyOrder.TotalPrice,
            Currency = shopifyOrder.Currency?.ToUpperInvariant() ?? "USD",
            Status = MapOrderStatus(shopifyOrder.FinancialStatus),
            Source = "shopify",
            LineItems = shopifyOrder.LineItems.Select(MapLineItem).ToList()
        };
    }

    private static string MapOrderStatus(string? financialStatus) => financialStatus switch
    {
        "paid" => "completed",
        "pending" => "pending",
        "refunded" => "refunded",
        "partially_refunded" => "partially_refunded",
        "voided" => "voided",
        _ => "unknown"
    };

    private static string MapWeightUnit(string? unit) => unit switch
    {
        "lb" or "lbs" => "lb",
        "kg" => "kg",
        "oz" => "oz",
        "g" => "g",
        _ => "lb"
    };

    private static string FormatCustomerName(ShopifyCustomer? customer)
    {
        if (customer is null) return "Guest";
        return $"{customer.FirstName} {customer.LastName}".Trim();
    }

    private static OrderLineItemDto MapLineItem(ShopifyLineItem li) => new()
    {
        ExternalLineItemId = li.Id.ToString(),
        Sku = li.Sku,
        Name = li.Title,
        Quantity = li.Quantity,
        UnitPrice = li.Price,
        DiscountAmount = li.TotalDiscount
    };
}

// File: src/POS.Infrastructure/Integrations/Amazon/AmazonAcl.cs
namespace POS.Infrastructure.Integrations.Amazon;

public class AmazonAcl
{
    public ItemDto MapToItem(AmazonCatalogItem catalogItem)
    {
        return new ItemDto
        {
            Sku = catalogItem.SellerSku,
            Name = catalogItem.ItemName,
            Price = catalogItem.YourPrice?.Amount ?? 0m,
            Barcode = catalogItem.ExternalIds?.FirstOrDefault(e => e.Type == "UPC")?.Value,
            IsActive = catalogItem.Status == "Active",
            ExternalIds = new Dictionary<string, string>
            {
                ["amazon_asin"] = catalogItem.Asin,
                ["amazon_seller_sku"] = catalogItem.SellerSku
            }
        };
    }
    // ... additional Amazon-specific mappings
}

// File: src/POS.Infrastructure/Integrations/Google/GoogleMerchantAcl.cs
namespace POS.Infrastructure.Integrations.Google;

public class GoogleMerchantAcl
{
    public GoogleProductInput MapToGoogleProduct(ItemDto item, string targetCountry)
    {
        return new GoogleProductInput
        {
            OfferId = item.Sku,
            Title = item.Name,
            Price = new GooglePrice { Value = item.Price.ToString("F2"), Currency = "USD" },
            Availability = item.TotalQuantityOnHand > 0 ? "in_stock" : "out_of_stock",
            Condition = "new",
            Channel = "online",
            ContentLanguage = "en",
            TargetCountry = targetCountry
        };
    }
    // ... additional Google-specific mappings
}

Provider Resolution

// File: src/POS.Infrastructure/Integrations/IntegrationProviderFactory.cs
namespace POS.Infrastructure.Integrations;

public class IntegrationProviderFactory
{
    private readonly IEnumerable<IIntegrationProvider> _providers;

    public IntegrationProviderFactory(IEnumerable<IIntegrationProvider> providers)
    {
        _providers = providers;
    }

    public IIntegrationProvider? GetProvider(string providerName)
    {
        return _providers.FirstOrDefault(
            p => p.ProviderName.Equals(providerName, StringComparison.OrdinalIgnoreCase));
    }

    public IEnumerable<IIntegrationProvider> GetActiveProviders(string tenantId)
    {
        // Returns all providers configured for this tenant
        return _providers.Where(p => p.IsConfiguredFor(tenantId));
    }
}

13.6 Compliance Deadlines

External API providers enforce deprecation schedules. Non-compliance results in broken integrations.

ProviderDeadlineRequirementImpact if Missed
ShopifyApril 1, 2026All GraphQL mutations must use @idempotent directiveMutations without idempotency keys will be rejected
GoogleAugust 18, 2026Migrate from Content API for Shopping to Merchant API v1Content API endpoints return HTTP 404
AmazonOngoingSP-API replaces MWS (already sunset)MWS calls fail; must use SP-API exclusively
StripeOngoingAPI version pinning; deprecations announced 12 months aheadOld API versions eventually removed

Shopify @idempotent Directive

Shopify requires idempotent mutations for all GraphQL API calls. Our implementation must include the @idempotent directive and pass an idempotency key:

mutation CreateProduct @idempotent(key: "unique-key-per-operation") {
  productCreate(input: { title: "New Product", productType: "Apparel" }) {
    product {
      id
      title
    }
    userErrors {
      field
      message
    }
  }
}
// Shopify GraphQL client must include idempotency key
var graphqlRequest = new
{
    query = @"mutation CreateProduct @idempotent(key: ""$KEY"") { ... }".Replace("$KEY", idempotencyKey),
    variables = new { input = productInput }
};

Google Merchant API v1 Migration

The Content API for Shopping sunsets August 18, 2026. All product feed, price update, and availability sync calls must migrate to Merchant API v1:

Content API (DEPRECATED):  POST /content/v2.1/products
Merchant API v1 (CURRENT): POST /products/v1beta/{parent}/productInputs:insert

Our GoogleMerchantAcl already targets the v1 API. See Chapter 05 (Architecture Components), Section 6.12 for the complete Google Merchant integration specification.


13.7 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 (5 failures in 60s window, 30s cooldown)
  • Anti-Corruption Layer (ACL): Per-provider mapping classes (ShopifyAcl, AmazonAcl, GoogleMerchantAcl) preventing external models from leaking into domain
  • Credential management: HashiCorp Vault for secrets with in-memory caching
  • Compliance deadlines: Shopify @idempotent (April 2026), Google Merchant API v1 (August 2026)
  • 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.