Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chapter 18: 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.


18.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      │
        └───────────────┘   └───────────────┘        └───────────────┘

18.2 Shopify Integration

18.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";
}

18.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;
    }
}

18.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);
    }
}

18.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;
    }
}

18.3 Payment Processing

18.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                                     │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

18.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);
}

18.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"
    };
}

18.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
}

18.4 External API Patterns

18.4.1 Retry with Polly

// 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));
    }
}

18.4.2 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");
    }
}

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

Next: Part V covers frontend implementation with the POS Client application.