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.
| Provider | Deadline | Requirement | Impact if Missed |
|---|---|---|---|
| Shopify | April 1, 2026 | All GraphQL mutations must use @idempotent directive | Mutations without idempotency keys will be rejected |
| August 18, 2026 | Migrate from Content API for Shopping to Merchant API v1 | Content API endpoints return HTTP 404 | |
| Amazon | Ongoing | SP-API replaces MWS (already sunset) | MWS calls fail; must use SP-API exclusively |
| Stripe | Ongoing | API version pinning; deprecations announced 12 months ahead | Old 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 Range | Domain | Description |
|---|---|---|
| ERR-6001–6009 | General | Cross-integration errors (auth failures, timeout, config missing) |
| ERR-6010–6029 | Shopify | Webhook verification, product sync, inventory sync, order import |
| ERR-6030–6049 | Amazon SP-API | Feed submission, listing sync, order pull, inventory push |
| ERR-6050–6069 | Google Merchant | Product feed, price update, availability sync |
| ERR-6070–6089 | Payment Processors | Stripe/Square terminal errors, batch settlement, refund failures |
| ERR-6090–6099 | Email/Shipping | Notification 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
| Attribute | Value |
|---|---|
| Version | 5.0.0 |
| Created | 2025-12-29 |
| Updated | 2026-02-25 |
| Author | Claude Code |
| Status | Active |
| Part | IV - Backend |
| Chapter | 13 of 32 |
This chapter is part of the POS Blueprint Book. All content is self-contained.