Chapter 13: Integration Patterns
Shopify, Payment Processing, and External API Integration
This chapter provides complete implementation patterns for integrating with Shopify, payment processors (Stripe/Square), and external APIs with PCI-DSS compliance.
13.1 Integration Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ POS Platform │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ Shopify │ │ Payment │ │ Other Integrations │ │
│ │ Integration │ │ Processing │ │ (Accounting, etc.) │ │
│ └────────┬────────┘ └────────┬────────┘ └───────────┬─────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Integration Service Layer │ │
│ │ • Webhook handlers • Payment abstraction │ │
│ │ • Retry logic • Token management │ │
│ │ • Event publishing • Audit logging │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Shopify │ │ Stripe API │ │ QuickBooks │
│ Admin API │ │ Square API │ │ Online │
└───────────────┘ └───────────────┘ └───────────────┘
13.2 Shopify Integration
13.2.1 Webhook Configuration
// File: src/POS.Infrastructure/Integrations/Shopify/ShopifyWebhookConfig.cs
namespace POS.Infrastructure.Integrations.Shopify;
public static class ShopifyWebhookTopics
{
// Order webhooks
public const string OrdersCreate = "orders/create";
public const string OrdersUpdated = "orders/updated";
public const string OrdersCancelled = "orders/cancelled";
public const string OrdersFulfilled = "orders/fulfilled";
public const string OrdersPaid = "orders/paid";
// Inventory webhooks
public const string InventoryLevelsUpdate = "inventory_levels/update";
public const string InventoryLevelsConnect = "inventory_levels/connect";
public const string InventoryLevelsDisconnect = "inventory_levels/disconnect";
// Product webhooks
public const string ProductsCreate = "products/create";
public const string ProductsUpdate = "products/update";
public const string ProductsDelete = "products/delete";
// Customer webhooks
public const string CustomersCreate = "customers/create";
public const string CustomersUpdate = "customers/update";
// Refund webhooks
public const string RefundsCreate = "refunds/create";
}
13.2.2 Webhook Controller
// File: src/POS.Api/Controllers/ShopifyWebhookController.cs
using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text;
namespace POS.Api.Controllers;
[ApiController]
[Route("api/v1/webhooks/shopify")]
public class ShopifyWebhookController : ControllerBase
{
private readonly IShopifyWebhookHandler _webhookHandler;
private readonly IShopifyCredentialService _credentialService;
private readonly ILogger<ShopifyWebhookController> _logger;
public ShopifyWebhookController(
IShopifyWebhookHandler webhookHandler,
IShopifyCredentialService credentialService,
ILogger<ShopifyWebhookController> logger)
{
_webhookHandler = webhookHandler;
_credentialService = credentialService;
_logger = logger;
}
[HttpPost("{tenantId}")]
public async Task<IActionResult> HandleWebhook(
string tenantId,
CancellationToken ct)
{
// Read raw body for HMAC verification
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var rawBody = await reader.ReadToEndAsync();
Request.Body.Position = 0;
// Verify HMAC signature
var hmacHeader = Request.Headers["X-Shopify-Hmac-Sha256"].FirstOrDefault();
if (string.IsNullOrEmpty(hmacHeader))
{
_logger.LogWarning("Missing HMAC header for tenant {TenantId}", tenantId);
return Unauthorized();
}
var credentials = await _credentialService.GetCredentialsAsync(tenantId, ct);
if (credentials is null)
{
_logger.LogWarning("No Shopify credentials for tenant {TenantId}", tenantId);
return NotFound();
}
if (!VerifyHmac(rawBody, hmacHeader, credentials.WebhookSecret))
{
_logger.LogWarning("Invalid HMAC for tenant {TenantId}", tenantId);
return Unauthorized();
}
// Extract webhook topic
var topic = Request.Headers["X-Shopify-Topic"].FirstOrDefault();
var shopDomain = Request.Headers["X-Shopify-Shop-Domain"].FirstOrDefault();
var webhookId = Request.Headers["X-Shopify-Webhook-Id"].FirstOrDefault();
_logger.LogInformation(
"Received Shopify webhook {Topic} from {Shop} for tenant {TenantId}",
topic, shopDomain, tenantId);
// Queue for processing (respond quickly to Shopify)
await _webhookHandler.QueueWebhookAsync(new ShopifyWebhookEvent
{
TenantId = tenantId,
Topic = topic!,
ShopDomain = shopDomain!,
WebhookId = webhookId!,
Payload = rawBody,
ReceivedAt = DateTime.UtcNow
}, ct);
return Ok();
}
private static bool VerifyHmac(string body, string hmacHeader, string secret)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
var computedHmac = Convert.ToBase64String(hash);
return hmacHeader == computedHmac;
}
}
13.2.3 Webhook Handler Implementation
// File: src/POS.Infrastructure/Integrations/Shopify/ShopifyWebhookHandler.cs
using System.Text.Json;
using MassTransit;
namespace POS.Infrastructure.Integrations.Shopify;
public class ShopifyWebhookHandler : IShopifyWebhookHandler
{
private readonly IPublishEndpoint _publishEndpoint;
private readonly IInventoryService _inventoryService;
private readonly IOrderService _orderService;
private readonly IItemService _itemService;
private readonly ITenantContext _tenantContext;
private readonly ILogger<ShopifyWebhookHandler> _logger;
public ShopifyWebhookHandler(
IPublishEndpoint publishEndpoint,
IInventoryService inventoryService,
IOrderService orderService,
IItemService itemService,
ITenantContext tenantContext,
ILogger<ShopifyWebhookHandler> logger)
{
_publishEndpoint = publishEndpoint;
_inventoryService = inventoryService;
_orderService = orderService;
_itemService = itemService;
_tenantContext = tenantContext;
_logger = logger;
}
public async Task QueueWebhookAsync(
ShopifyWebhookEvent webhook,
CancellationToken ct)
{
// Publish to message queue for async processing
await _publishEndpoint.Publish(webhook, ct);
}
public async Task ProcessWebhookAsync(
ShopifyWebhookEvent webhook,
CancellationToken ct)
{
_tenantContext.SetTenant(webhook.TenantId);
try
{
switch (webhook.Topic)
{
case ShopifyWebhookTopics.OrdersCreate:
await HandleOrderCreatedAsync(webhook.Payload, ct);
break;
case ShopifyWebhookTopics.OrdersUpdated:
await HandleOrderUpdatedAsync(webhook.Payload, ct);
break;
case ShopifyWebhookTopics.OrdersCancelled:
await HandleOrderCancelledAsync(webhook.Payload, ct);
break;
case ShopifyWebhookTopics.InventoryLevelsUpdate:
await HandleInventoryUpdateAsync(webhook.Payload, ct);
break;
case ShopifyWebhookTopics.ProductsCreate:
case ShopifyWebhookTopics.ProductsUpdate:
await HandleProductUpdateAsync(webhook.Payload, ct);
break;
case ShopifyWebhookTopics.ProductsDelete:
await HandleProductDeleteAsync(webhook.Payload, ct);
break;
default:
_logger.LogWarning(
"Unhandled webhook topic: {Topic}",
webhook.Topic);
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error processing webhook {Topic} for tenant {TenantId}",
webhook.Topic, webhook.TenantId);
throw;
}
}
private async Task HandleOrderCreatedAsync(string payload, CancellationToken ct)
{
var order = JsonSerializer.Deserialize<ShopifyOrder>(payload,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (order is null) return;
_logger.LogInformation(
"Processing Shopify order {OrderNumber} ({OrderId})",
order.OrderNumber, order.Id);
// Import order to POS system
var importResult = await _orderService.ImportShopifyOrderAsync(
new ImportShopifyOrderCommand
{
ShopifyOrderId = order.Id.ToString(),
OrderNumber = order.OrderNumber,
CustomerEmail = order.Email,
CustomerName = $"{order.Customer?.FirstName} {order.Customer?.LastName}",
TotalPrice = order.TotalPrice,
Currency = order.Currency,
LineItems = order.LineItems.Select(li => new ImportedLineItem
{
ShopifyLineItemId = li.Id.ToString(),
Sku = li.Sku,
Title = li.Title,
Quantity = li.Quantity,
Price = li.Price,
VariantId = li.VariantId?.ToString()
}).ToList(),
FulfillmentStatus = order.FulfillmentStatus,
FinancialStatus = order.FinancialStatus,
ShippingAddress = order.ShippingAddress != null
? new AddressDto
{
Address1 = order.ShippingAddress.Address1,
Address2 = order.ShippingAddress.Address2,
City = order.ShippingAddress.City,
Province = order.ShippingAddress.Province,
Zip = order.ShippingAddress.Zip,
Country = order.ShippingAddress.Country
}
: null,
CreatedAt = order.CreatedAt
}, ct);
if (!importResult.IsSuccess)
{
_logger.LogError(
"Failed to import Shopify order {OrderNumber}: {Error}",
order.OrderNumber, importResult.Error?.Message);
}
}
private async Task HandleInventoryUpdateAsync(string payload, CancellationToken ct)
{
var update = JsonSerializer.Deserialize<ShopifyInventoryLevel>(payload,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (update is null) return;
_logger.LogInformation(
"Processing inventory update for variant {InventoryItemId} at location {LocationId}",
update.InventoryItemId, update.LocationId);
// Find item by Shopify inventory item ID
var item = await _itemService.GetByShopifyInventoryItemIdAsync(
update.InventoryItemId.ToString(), ct);
if (item is null)
{
_logger.LogWarning(
"Item not found for Shopify inventory item {InventoryItemId}",
update.InventoryItemId);
return;
}
// Find POS location by Shopify location ID
var location = await _inventoryService.GetLocationByShopifyIdAsync(
update.LocationId.ToString(), ct);
if (location is null)
{
_logger.LogWarning(
"Location not found for Shopify location {LocationId}",
update.LocationId);
return;
}
// Update inventory (from Shopify, not triggering sync back)
await _inventoryService.SyncFromShopifyAsync(
new SyncInventoryCommand
{
ItemId = item.Id,
LocationId = location.Id,
Quantity = update.Available,
Source = "shopify_webhook",
ShopifyUpdatedAt = update.UpdatedAt
}, ct);
}
private async Task HandleProductUpdateAsync(string payload, CancellationToken ct)
{
var product = JsonSerializer.Deserialize<ShopifyProduct>(payload,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (product is null) return;
_logger.LogInformation(
"Processing product update for {ProductTitle} ({ProductId})",
product.Title, product.Id);
foreach (var variant in product.Variants)
{
var existingItem = await _itemService.GetByShopifyVariantIdAsync(
variant.Id.ToString(), ct);
if (existingItem is not null)
{
// Update existing item
await _itemService.UpdateFromShopifyAsync(
existingItem.Id,
new UpdateFromShopifyCommand
{
Name = $"{product.Title} - {variant.Title}",
Sku = variant.Sku,
Barcode = variant.Barcode,
Price = variant.Price,
CompareAtPrice = variant.CompareAtPrice,
Weight = variant.Weight,
WeightUnit = variant.WeightUnit
}, ct);
}
else
{
_logger.LogInformation(
"New Shopify variant {VariantId} not linked to POS item",
variant.Id);
}
}
}
private async Task HandleOrderCancelledAsync(string payload, CancellationToken ct)
{
var order = JsonSerializer.Deserialize<ShopifyOrder>(payload,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (order is null) return;
await _orderService.CancelShopifyOrderAsync(order.Id.ToString(), ct);
}
private async Task HandleProductDeleteAsync(string payload, CancellationToken ct)
{
var deleteEvent = JsonSerializer.Deserialize<ShopifyProductDelete>(payload,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (deleteEvent is null) return;
_logger.LogInformation(
"Shopify product {ProductId} deleted - marking POS items as inactive",
deleteEvent.Id);
await _itemService.DeactivateByShopifyProductIdAsync(
deleteEvent.Id.ToString(), ct);
}
}
13.2.4 Shopify API Client
// File: src/POS.Infrastructure/Integrations/Shopify/ShopifyClient.cs
using System.Net.Http.Json;
using System.Text.Json;
namespace POS.Infrastructure.Integrations.Shopify;
public class ShopifyClient : IShopifyClient
{
private readonly HttpClient _httpClient;
private readonly IShopifyCredentialService _credentialService;
private readonly ILogger<ShopifyClient> _logger;
public ShopifyClient(
HttpClient httpClient,
IShopifyCredentialService credentialService,
ILogger<ShopifyClient> logger)
{
_httpClient = httpClient;
_credentialService = credentialService;
_logger = logger;
}
public async Task<bool> UpdateInventoryLevelAsync(
string tenantId,
string inventoryItemId,
string locationId,
int quantity,
CancellationToken ct)
{
var credentials = await _credentialService.GetCredentialsAsync(tenantId, ct);
if (credentials is null)
throw new InvalidOperationException($"No Shopify credentials for tenant {tenantId}");
var baseUrl = $"https://{credentials.ShopDomain}/admin/api/2024-01";
var request = new HttpRequestMessage(HttpMethod.Post,
$"{baseUrl}/inventory_levels/set.json");
request.Headers.Add("X-Shopify-Access-Token", credentials.AccessToken);
request.Content = JsonContent.Create(new
{
inventory_item_id = long.Parse(inventoryItemId),
location_id = long.Parse(locationId),
available = quantity
});
var response = await _httpClient.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(ct);
_logger.LogError(
"Failed to update Shopify inventory: {StatusCode} - {Error}",
response.StatusCode, error);
return false;
}
return true;
}
public async Task<bool> FulfillOrderAsync(
string tenantId,
string orderId,
string locationId,
IEnumerable<FulfillmentLineItem> lineItems,
string? trackingNumber,
string? trackingCompany,
CancellationToken ct)
{
var credentials = await _credentialService.GetCredentialsAsync(tenantId, ct);
if (credentials is null)
throw new InvalidOperationException($"No Shopify credentials for tenant {tenantId}");
var baseUrl = $"https://{credentials.ShopDomain}/admin/api/2024-01";
// First, get fulfillment order
var fulfillmentOrderRequest = new HttpRequestMessage(HttpMethod.Get,
$"{baseUrl}/orders/{orderId}/fulfillment_orders.json");
fulfillmentOrderRequest.Headers.Add("X-Shopify-Access-Token", credentials.AccessToken);
var foResponse = await _httpClient.SendAsync(fulfillmentOrderRequest, ct);
if (!foResponse.IsSuccessStatusCode)
{
_logger.LogError("Failed to get fulfillment orders for order {OrderId}", orderId);
return false;
}
var foResult = await foResponse.Content.ReadFromJsonAsync<FulfillmentOrdersResponse>(ct);
var fulfillmentOrder = foResult?.FulfillmentOrders?.FirstOrDefault();
if (fulfillmentOrder is null)
{
_logger.LogWarning("No fulfillment order found for order {OrderId}", orderId);
return false;
}
// Create fulfillment
var fulfillmentRequest = new HttpRequestMessage(HttpMethod.Post,
$"{baseUrl}/fulfillments.json");
fulfillmentRequest.Headers.Add("X-Shopify-Access-Token", credentials.AccessToken);
var fulfillmentPayload = new
{
fulfillment = new
{
line_items_by_fulfillment_order = new[]
{
new
{
fulfillment_order_id = fulfillmentOrder.Id,
fulfillment_order_line_items = lineItems.Select(li => new
{
id = li.FulfillmentOrderLineItemId,
quantity = li.Quantity
}).ToArray()
}
},
tracking_info = !string.IsNullOrEmpty(trackingNumber) ? new
{
number = trackingNumber,
company = trackingCompany
} : null,
notify_customer = true
}
};
fulfillmentRequest.Content = JsonContent.Create(fulfillmentPayload);
var response = await _httpClient.SendAsync(fulfillmentRequest, ct);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(ct);
_logger.LogError(
"Failed to create Shopify fulfillment: {StatusCode} - {Error}",
response.StatusCode, error);
return false;
}
return true;
}
}
13.3 Payment Processing
13.3.1 PCI-DSS Compliance Pattern
┌─────────────────────────────────────────────────────────────────────────────┐
│ PCI-DSS Compliant Payment Flow │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Card Data NEVER touches POS server │
│ 2. Use payment terminal or tokenization │
│ 3. Only store payment tokens │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Customer │ │ Payment │ │ Payment │ │
│ │ Card │───────►│ Terminal │───────►│ Processor │ │
│ └─────────────┘ └─────────────┘ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ POS │◄───────│ Token + │◄───────│ Response │ │
│ │ Server │ │ Last 4 │ │ (Success) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ Stored: payment_token, card_last_4, card_brand │
│ NOT Stored: card_number, cvv, expiry │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
13.3.2 Payment Service Interface
// File: src/POS.Application/Interfaces/IPaymentService.cs
namespace POS.Application.Interfaces;
public interface IPaymentService
{
Task<Result<PaymentResult>> ProcessPaymentAsync(
ProcessPaymentCommand command,
CancellationToken ct = default);
Task<Result<RefundResult>> ProcessRefundAsync(
ProcessRefundCommand command,
CancellationToken ct = default);
Task<Result> VoidPaymentAsync(
string transactionId,
CancellationToken ct = default);
Task<PaymentMethodsResult> GetAvailableMethodsAsync(
string locationId,
CancellationToken ct = default);
// Terminal operations
Task<TerminalStatus> GetTerminalStatusAsync(
string terminalId,
CancellationToken ct = default);
Task<Result<TerminalPaymentIntent>> CreateTerminalPaymentIntentAsync(
CreateTerminalPaymentCommand command,
CancellationToken ct = default);
Task<Result<PaymentResult>> CaptureTerminalPaymentAsync(
string paymentIntentId,
CancellationToken ct = default);
Task<Result> CancelTerminalPaymentAsync(
string paymentIntentId,
CancellationToken ct = default);
}
13.3.3 Stripe Terminal Integration
// File: src/POS.Infrastructure/Payments/StripePaymentService.cs
using Stripe;
using Stripe.Terminal;
namespace POS.Infrastructure.Payments;
public class StripePaymentService : IPaymentService
{
private readonly IPaymentCredentialService _credentialService;
private readonly ITenantContext _tenantContext;
private readonly IAuditLogger _auditLogger;
private readonly ILogger<StripePaymentService> _logger;
public StripePaymentService(
IPaymentCredentialService credentialService,
ITenantContext tenantContext,
IAuditLogger auditLogger,
ILogger<StripePaymentService> logger)
{
_credentialService = credentialService;
_tenantContext = tenantContext;
_auditLogger = auditLogger;
_logger = logger;
}
public async Task<Result<PaymentResult>> ProcessPaymentAsync(
ProcessPaymentCommand command,
CancellationToken ct = default)
{
var credentials = await _credentialService.GetStripeCredentialsAsync(
_tenantContext.TenantId!, ct);
if (credentials is null)
return Result<PaymentResult>.Failure(
DomainError.PaymentNotConfigured("Stripe"));
StripeConfiguration.ApiKey = credentials.SecretKey;
try
{
switch (command.Method)
{
case PaymentMethod.CreditCard when command.TerminalId is not null:
return await ProcessTerminalPaymentAsync(command, ct);
case PaymentMethod.CreditCard when command.PaymentToken is not null:
return await ProcessTokenPaymentAsync(command, ct);
case PaymentMethod.Cash:
return await ProcessCashPaymentAsync(command, ct);
default:
return Result<PaymentResult>.Failure(
DomainError.InvalidPaymentMethod(command.Method.ToString()));
}
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe payment failed: {Code}", ex.StripeError?.Code);
return Result<PaymentResult>.Failure(
DomainError.PaymentFailed(ex.StripeError?.Message ?? ex.Message));
}
}
private async Task<Result<PaymentResult>> ProcessTerminalPaymentAsync(
ProcessPaymentCommand command,
CancellationToken ct)
{
_logger.LogInformation(
"Processing terminal payment of {Amount} on terminal {TerminalId}",
command.Amount, command.TerminalId);
// Create PaymentIntent
var paymentIntentService = new PaymentIntentService();
var paymentIntent = await paymentIntentService.CreateAsync(
new PaymentIntentCreateOptions
{
Amount = (long)(command.Amount * 100), // Convert to cents
Currency = "usd",
PaymentMethodTypes = new List<string> { "card_present" },
CaptureMethod = "automatic",
Metadata = new Dictionary<string, string>
{
["order_id"] = command.OrderId,
["tenant_id"] = _tenantContext.TenantId!,
["location_id"] = command.LocationId
}
}, cancellationToken: ct);
// Process on terminal
var readerService = new ReaderService();
var processResult = await readerService.ProcessPaymentIntentAsync(
command.TerminalId,
new ReaderProcessPaymentIntentOptions
{
PaymentIntent = paymentIntent.Id
}, cancellationToken: ct);
// Wait for payment to complete (simplified - real impl would poll)
var updatedIntent = await WaitForPaymentCompletionAsync(
paymentIntent.Id, TimeSpan.FromSeconds(60), ct);
if (updatedIntent.Status != "succeeded")
{
return Result<PaymentResult>.Failure(
DomainError.PaymentFailed(
$"Terminal payment failed with status: {updatedIntent.Status}"));
}
var charge = updatedIntent.LatestCharge;
await _auditLogger.LogAsync(new AuditEvent
{
TenantId = _tenantContext.TenantId!,
EventType = "PaymentProcessed",
Details = $"Card payment {command.Amount:C} via terminal {command.TerminalId}",
ReferenceId = paymentIntent.Id,
ReferenceType = "StripePaymentIntent"
}, ct);
return Result<PaymentResult>.Success(new PaymentResult
{
Success = true,
TransactionId = paymentIntent.Id,
ChargeId = charge?.Id,
Amount = command.Amount,
CardLast4 = charge?.PaymentMethodDetails?.CardPresent?.Last4,
CardBrand = charge?.PaymentMethodDetails?.CardPresent?.Brand,
AuthorizationCode = charge?.AuthorizationCode
});
}
private async Task<Result<PaymentResult>> ProcessTokenPaymentAsync(
ProcessPaymentCommand command,
CancellationToken ct)
{
_logger.LogInformation(
"Processing token payment of {Amount}",
command.Amount);
var paymentIntentService = new PaymentIntentService();
var paymentIntent = await paymentIntentService.CreateAsync(
new PaymentIntentCreateOptions
{
Amount = (long)(command.Amount * 100),
Currency = "usd",
PaymentMethod = command.PaymentToken,
Confirm = true,
Metadata = new Dictionary<string, string>
{
["order_id"] = command.OrderId,
["tenant_id"] = _tenantContext.TenantId!
}
}, cancellationToken: ct);
if (paymentIntent.Status != "succeeded")
{
return Result<PaymentResult>.Failure(
DomainError.PaymentFailed(
$"Payment failed with status: {paymentIntent.Status}"));
}
var charge = paymentIntent.LatestCharge;
return Result<PaymentResult>.Success(new PaymentResult
{
Success = true,
TransactionId = paymentIntent.Id,
ChargeId = charge?.Id,
Amount = command.Amount,
CardLast4 = charge?.PaymentMethodDetails?.Card?.Last4,
CardBrand = charge?.PaymentMethodDetails?.Card?.Brand
});
}
private Task<Result<PaymentResult>> ProcessCashPaymentAsync(
ProcessPaymentCommand command,
CancellationToken ct)
{
// Cash payments don't need external processing
var transactionId = $"CASH-{Guid.NewGuid():N}"[..24];
_logger.LogInformation(
"Recording cash payment of {Amount}",
command.Amount);
return Task.FromResult(Result<PaymentResult>.Success(new PaymentResult
{
Success = true,
TransactionId = transactionId,
Amount = command.Amount
}));
}
public async Task<Result<RefundResult>> ProcessRefundAsync(
ProcessRefundCommand command,
CancellationToken ct = default)
{
var credentials = await _credentialService.GetStripeCredentialsAsync(
_tenantContext.TenantId!, ct);
if (credentials is null)
return Result<RefundResult>.Failure(
DomainError.PaymentNotConfigured("Stripe"));
StripeConfiguration.ApiKey = credentials.SecretKey;
try
{
var refundService = new RefundService();
var refund = await refundService.CreateAsync(
new RefundCreateOptions
{
PaymentIntent = command.OriginalTransactionId,
Amount = (long)(command.Amount * 100),
Reason = MapRefundReason(command.Reason),
Metadata = new Dictionary<string, string>
{
["refund_order_id"] = command.RefundOrderId,
["original_order_id"] = command.OriginalOrderId
}
}, cancellationToken: ct);
await _auditLogger.LogAsync(new AuditEvent
{
TenantId = _tenantContext.TenantId!,
EventType = "RefundProcessed",
Details = $"Refund {command.Amount:C} for order {command.OriginalOrderId}",
ReferenceId = refund.Id,
ReferenceType = "StripeRefund"
}, ct);
return Result<RefundResult>.Success(new RefundResult
{
Success = true,
TransactionId = refund.Id,
Amount = command.Amount,
Status = refund.Status
});
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe refund failed: {Code}", ex.StripeError?.Code);
return Result<RefundResult>.Failure(
DomainError.RefundFailed(ex.StripeError?.Message ?? ex.Message));
}
}
public async Task<Result> VoidPaymentAsync(
string transactionId,
CancellationToken ct = default)
{
var credentials = await _credentialService.GetStripeCredentialsAsync(
_tenantContext.TenantId!, ct);
if (credentials is null)
return Result.Failure(DomainError.PaymentNotConfigured("Stripe"));
StripeConfiguration.ApiKey = credentials.SecretKey;
try
{
var paymentIntentService = new PaymentIntentService();
await paymentIntentService.CancelAsync(transactionId, cancellationToken: ct);
await _auditLogger.LogAsync(new AuditEvent
{
TenantId = _tenantContext.TenantId!,
EventType = "PaymentVoided",
ReferenceId = transactionId,
ReferenceType = "StripePaymentIntent"
}, ct);
return Result.Success();
}
catch (StripeException ex) when (ex.StripeError?.Code == "payment_intent_unexpected_state")
{
// Already captured - need to refund instead
var refundService = new RefundService();
await refundService.CreateAsync(
new RefundCreateOptions { PaymentIntent = transactionId },
cancellationToken: ct);
return Result.Success();
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe void failed: {Code}", ex.StripeError?.Code);
return Result.Failure(
DomainError.VoidFailed(ex.StripeError?.Message ?? ex.Message));
}
}
private async Task<PaymentIntent> WaitForPaymentCompletionAsync(
string paymentIntentId,
TimeSpan timeout,
CancellationToken ct)
{
var paymentIntentService = new PaymentIntentService();
var startTime = DateTime.UtcNow;
while (DateTime.UtcNow - startTime < timeout)
{
var intent = await paymentIntentService.GetAsync(
paymentIntentId, cancellationToken: ct);
if (intent.Status is "succeeded" or "canceled" or "requires_payment_method")
{
return intent;
}
await Task.Delay(1000, ct);
}
throw new TimeoutException("Payment processing timed out");
}
private static string MapRefundReason(RefundReason reason) => reason switch
{
RefundReason.CustomerRequest => "requested_by_customer",
RefundReason.Duplicate => "duplicate",
RefundReason.Fraudulent => "fraudulent",
_ => "requested_by_customer"
};
}
13.3.4 Square Integration Pattern
// File: src/POS.Infrastructure/Payments/SquarePaymentService.cs
using Square;
using Square.Models;
namespace POS.Infrastructure.Payments;
public class SquarePaymentService : IPaymentService
{
private readonly IPaymentCredentialService _credentialService;
private readonly ITenantContext _tenantContext;
private readonly ILogger<SquarePaymentService> _logger;
public SquarePaymentService(
IPaymentCredentialService credentialService,
ITenantContext tenantContext,
ILogger<SquarePaymentService> logger)
{
_credentialService = credentialService;
_tenantContext = tenantContext;
_logger = logger;
}
public async Task<Result<PaymentResult>> ProcessPaymentAsync(
ProcessPaymentCommand command,
CancellationToken ct = default)
{
var credentials = await _credentialService.GetSquareCredentialsAsync(
_tenantContext.TenantId!, ct);
if (credentials is null)
return Result<PaymentResult>.Failure(
DomainError.PaymentNotConfigured("Square"));
var client = new SquareClient.Builder()
.Environment(credentials.IsSandbox
? Square.Environment.Sandbox
: Square.Environment.Production)
.AccessToken(credentials.AccessToken)
.Build();
try
{
// Create terminal checkout for card-present
if (command.TerminalId is not null)
{
var checkoutRequest = new CreateTerminalCheckoutRequest.Builder(
Guid.NewGuid().ToString(),
new TerminalCheckout.Builder(
new Money.Builder()
.Amount((long)(command.Amount * 100))
.Currency("USD")
.Build(),
command.TerminalId)
.ReferenceId(command.OrderId)
.Build())
.Build();
var checkoutResponse = await client.TerminalApi.CreateTerminalCheckoutAsync(
checkoutRequest);
if (checkoutResponse.Errors?.Any() == true)
{
var error = checkoutResponse.Errors.First();
return Result<PaymentResult>.Failure(
DomainError.PaymentFailed(error.Detail));
}
var checkout = checkoutResponse.Checkout;
// Poll for completion
var completedCheckout = await WaitForCheckoutCompletionAsync(
client, checkout.Id, TimeSpan.FromSeconds(60), ct);
if (completedCheckout.Status != "COMPLETED")
{
return Result<PaymentResult>.Failure(
DomainError.PaymentFailed(
$"Checkout failed with status: {completedCheckout.Status}"));
}
return Result<PaymentResult>.Success(new PaymentResult
{
Success = true,
TransactionId = completedCheckout.PaymentIds?.FirstOrDefault(),
Amount = command.Amount,
CardLast4 = completedCheckout.CardDetails?.Card?.Last4,
CardBrand = completedCheckout.CardDetails?.Card?.CardBrand
});
}
else
{
// Token-based payment
var paymentRequest = new CreatePaymentRequest.Builder(
command.PaymentToken!,
Guid.NewGuid().ToString())
.AmountMoney(new Money.Builder()
.Amount((long)(command.Amount * 100))
.Currency("USD")
.Build())
.LocationId(credentials.LocationId)
.ReferenceId(command.OrderId)
.Build();
var paymentResponse = await client.PaymentsApi.CreatePaymentAsync(
paymentRequest);
if (paymentResponse.Errors?.Any() == true)
{
var error = paymentResponse.Errors.First();
return Result<PaymentResult>.Failure(
DomainError.PaymentFailed(error.Detail));
}
var payment = paymentResponse.Payment;
return Result<PaymentResult>.Success(new PaymentResult
{
Success = true,
TransactionId = payment.Id,
Amount = command.Amount,
CardLast4 = payment.CardDetails?.Card?.Last4,
CardBrand = payment.CardDetails?.Card?.CardBrand
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Square payment failed");
return Result<PaymentResult>.Failure(
DomainError.PaymentFailed(ex.Message));
}
}
private async Task<TerminalCheckout> WaitForCheckoutCompletionAsync(
SquareClient client,
string checkoutId,
TimeSpan timeout,
CancellationToken ct)
{
var startTime = DateTime.UtcNow;
while (DateTime.UtcNow - startTime < timeout)
{
var response = await client.TerminalApi.GetTerminalCheckoutAsync(checkoutId);
var checkout = response.Checkout;
if (checkout.Status is "COMPLETED" or "CANCELED")
{
return checkout;
}
await Task.Delay(1000, ct);
}
throw new TimeoutException("Checkout processing timed out");
}
// ... other interface methods
}
13.4 External API Patterns
13.4.1 Circuit Breaker Pattern with Polly v8
The Circuit Breaker pattern prevents cascading failures when external services (Shopify, payment processors) become unavailable. Polly v8 introduces a new fluent API with improved resilience pipelines.
Circuit Breaker States
+------------------------------------------------------------------+
| CIRCUIT BREAKER STATES |
+------------------------------------------------------------------+
| |
| CLOSED OPEN HALF-OPEN |
| (Normal Flow) (Fail Fast) (Test Recovery) |
| |
| ┌─────────┐ ┌─────────┐ ┌─────────┐ |
| │ Request │ │ Request │ │ Request │ |
| │ passes │ │ blocked │ │ limited │ |
| │ through │ │ (fast │ │ (test │ |
| │ │ │ fail) │ │ probe) │ |
| └────┬────┘ └────┬────┘ └────┬────┘ |
| │ │ │ |
| ▼ ▼ ▼ |
| ┌─────────┐ ┌─────────┐ ┌─────────┐ |
| │ Track │ │ Return │ │ If OK: │ |
| │ failures│ │ cached/ │ │ → CLOSED│ |
| │ If > 5: │ │ fallback│ │ If fail:│ |
| │ → OPEN │ │ After │ │ → OPEN │ |
| │ │ │ timeout:│ │ │ |
| │ │ │→HALF-OPN│ │ │ |
| └─────────┘ └─────────┘ └─────────┘ |
| |
+------------------------------------------------------------------+
Polly v8 Configuration
// File: src/POS.Infrastructure/Http/ResilienceConfiguration.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;
using Polly;
using Polly.CircuitBreaker;
using Polly.Retry;
using Polly.Timeout;
namespace POS.Infrastructure.Http;
public static class ResilienceConfiguration
{
public static IServiceCollection AddResilientHttpClients(
this IServiceCollection services)
{
// Shopify client with full resilience pipeline
services.AddHttpClient<IShopifyClient, ShopifyClient>()
.AddResilienceHandler("shopify", ConfigureShopifyResilience);
// Payment processor with stricter circuit breaker
services.AddHttpClient<IStripeClient, StripeClient>()
.AddResilienceHandler("stripe", ConfigurePaymentResilience);
services.AddHttpClient<ISquareClient, SquareClient>()
.AddResilienceHandler("square", ConfigurePaymentResilience);
return services;
}
private static void ConfigureShopifyResilience(ResiliencePipelineBuilder<HttpResponseMessage> builder)
{
builder
// 1. Timeout for individual requests
.AddTimeout(new TimeoutStrategyOptions
{
Timeout = TimeSpan.FromSeconds(10),
OnTimeout = args =>
{
Log.Warning("Shopify request timed out after {Timeout}s",
args.Timeout.TotalSeconds);
return default;
}
})
// 2. Retry with exponential backoff
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
{
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.Handle<TimeoutRejectedException>()
.HandleResult(r => r.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
.HandleResult(r => (int)r.StatusCode >= 500),
MaxRetryAttempts = 3,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true, // Prevents thundering herd
OnRetry = args =>
{
Log.Warning(
"Retrying Shopify request. Attempt {Attempt} after {Delay}ms. " +
"Status: {StatusCode}",
args.AttemptNumber,
args.RetryDelay.TotalMilliseconds,
args.Outcome.Result?.StatusCode);
return default;
}
})
// 3. Circuit Breaker
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
{
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.Handle<TimeoutRejectedException>()
.HandleResult(r => (int)r.StatusCode >= 500),
// Open circuit after 5 failures in 30 seconds
FailureRatio = 0.5, // 50% failure rate
SamplingDuration = TimeSpan.FromSeconds(30),
MinimumThroughput = 5, // Minimum requests before evaluating
// Stay open for 30 seconds before testing
BreakDuration = TimeSpan.FromSeconds(30),
OnOpened = args =>
{
Log.Error(
"Shopify circuit OPENED. Breaking for {Duration}s. " +
"Reason: {Exception}",
args.BreakDuration.TotalSeconds,
args.Outcome.Exception?.Message ?? "Server errors");
// Trigger alert
AlertService.SendCircuitBreakerAlert("Shopify", "OPEN");
return default;
},
OnClosed = args =>
{
Log.Information("Shopify circuit CLOSED. Service recovered.");
AlertService.SendCircuitBreakerAlert("Shopify", "CLOSED");
return default;
},
OnHalfOpened = args =>
{
Log.Information("Shopify circuit HALF-OPEN. Testing recovery...");
return default;
}
});
}
private static void ConfigurePaymentResilience(ResiliencePipelineBuilder<HttpResponseMessage> builder)
{
builder
// Shorter timeout for payment operations
.AddTimeout(new TimeoutStrategyOptions
{
Timeout = TimeSpan.FromSeconds(30) // Payment processors need more time
})
// Fewer retries for payments (idempotency concerns)
.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
{
ShouldHandle = new PredicateBuilder<HttpResponseMessage>()
.Handle<HttpRequestException>()
.HandleResult(r => r.StatusCode == System.Net.HttpStatusCode.TooManyRequests),
// Do NOT retry 5xx for payments - could cause double charges
MaxRetryAttempts = 2,
Delay = TimeSpan.FromSeconds(2),
BackoffType = DelayBackoffType.Linear
})
// Stricter circuit breaker for payments
.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
{
FailureRatio = 0.3, // Open at 30% failure rate
SamplingDuration = TimeSpan.FromSeconds(60),
MinimumThroughput = 3,
BreakDuration = TimeSpan.FromMinutes(1),
OnOpened = args =>
{
Log.Critical(
"PAYMENT CIRCUIT OPENED - Switching to fallback processor");
AlertService.SendCriticalAlert("Payment", "Circuit breaker opened");
return default;
}
});
}
}
Fallback Strategy for Circuit Breaker
// File: src/POS.Infrastructure/Http/FallbackShopifyClient.cs
public class ResilientShopifyClient : IShopifyClient
{
private readonly HttpClient _httpClient;
private readonly IShopifyCache _cache;
private readonly ILogger _logger;
private readonly ResiliencePipeline<HttpResponseMessage> _pipeline;
public async Task<ShopifyProduct?> GetProductAsync(
string tenantId,
string productId,
CancellationToken ct)
{
try
{
var response = await _pipeline.ExecuteAsync(async token =>
{
var request = CreateRequest(tenantId, $"/products/{productId}.json");
return await _httpClient.SendAsync(request, token);
}, ct);
if (response.IsSuccessStatusCode)
{
var product = await response.Content
.ReadFromJsonAsync<ShopifyProductResponse>(ct);
// Cache successful response for fallback
await _cache.SetProductAsync(productId, product.Product, ct);
return product.Product;
}
return null;
}
catch (BrokenCircuitException)
{
// Circuit is open - use cached data as fallback
_logger.LogWarning(
"Shopify circuit open. Using cached product {ProductId}",
productId);
return await _cache.GetProductAsync(productId, ct);
}
catch (TimeoutRejectedException)
{
_logger.LogWarning("Shopify request timed out for product {ProductId}", productId);
return await _cache.GetProductAsync(productId, ct);
}
}
}
Circuit Breaker Metrics for Grafana
# prometheus/alerts/circuit-breaker.yml
groups:
- name: circuit-breaker-alerts
rules:
- alert: CircuitBreakerOpen
expr: polly_circuit_breaker_state{state="open"} == 1
for: 0m
labels:
severity: critical
annotations:
summary: "Circuit breaker OPEN for {{ $labels.service }}"
description: "External service {{ $labels.service }} is failing"
runbook_url: "https://wiki/runbooks/circuit-breaker"
- alert: CircuitBreakerHalfOpen
expr: polly_circuit_breaker_state{state="half_open"} == 1
for: 5m
labels:
severity: warning
annotations:
summary: "Circuit breaker stuck in HALF-OPEN for {{ $labels.service }}"
13.4.2 API Rate Limiting
Rate limiting protects both the POS API from abuse and external APIs from being overwhelmed. Implementation uses Token Bucket algorithm for smooth traffic shaping.
Rate Limiting Architecture
+------------------------------------------------------------------+
| RATE LIMITING LAYERS |
+------------------------------------------------------------------+
| |
| Layer 1: Global Rate Limit (per IP) |
| └── 1000 requests/minute per IP |
| |
| Layer 2: Tenant Rate Limit (per API key) |
| └── Based on subscription tier |
| └── Free: 100/min, Pro: 1000/min, Enterprise: 10000/min │
| |
| Layer 3: Endpoint Rate Limit (per route) |
| └── /api/payments: 10/min per tenant (prevent fraud) │
| └── /api/reports: 5/min (expensive queries) │
| |
| Layer 4: Outbound Rate Limit (to external APIs) |
| └── Shopify: 40 requests/second per store │
| └── Stripe: 100 requests/second per account │
| |
+------------------------------------------------------------------+
Token Bucket Implementation
// File: src/POS.Infrastructure/RateLimiting/TokenBucketRateLimiter.cs
using System.Threading.RateLimiting;
public static class RateLimitingConfiguration
{
public static IServiceCollection AddRateLimiting(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddRateLimiter(options =>
{
// Global rate limit by IP
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(
httpContext =>
{
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetTokenBucketLimiter(
partitionKey: clientIp,
factory: _ => new TokenBucketRateLimiterOptions
{
TokenLimit = 100, // Bucket capacity
TokensPerPeriod = 100, // Refill amount
ReplenishmentPeriod = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 10, // Queue up to 10 requests
AutoReplenishment = true
});
});
// Tenant-based rate limit (by API key)
options.AddPolicy("tenant", httpContext =>
{
var tenantId = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();
var tier = GetTenantTier(httpContext, tenantId);
return RateLimitPartition.GetTokenBucketLimiter(
partitionKey: tenantId ?? "anonymous",
factory: _ => tier switch
{
"enterprise" => new TokenBucketRateLimiterOptions
{
TokenLimit = 10000,
TokensPerPeriod = 10000,
ReplenishmentPeriod = TimeSpan.FromMinutes(1)
},
"pro" => new TokenBucketRateLimiterOptions
{
TokenLimit = 1000,
TokensPerPeriod = 1000,
ReplenishmentPeriod = TimeSpan.FromMinutes(1)
},
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = 100,
TokensPerPeriod = 100,
ReplenishmentPeriod = TimeSpan.FromMinutes(1)
}
});
});
// Payment endpoint (stricter limit)
options.AddPolicy("payments", httpContext =>
{
var tenantId = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: tenantId ?? "anonymous",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 2
});
});
// Reporting endpoint (expensive queries)
options.AddPolicy("reports", httpContext =>
{
var tenantId = httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault();
return RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: tenantId ?? "anonymous",
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 6, // 10-second segments
QueueLimit = 0 // No queuing for reports
});
});
// Custom rejection response
options.OnRejected = async (context, token) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.HttpContext.Response.Headers.RetryAfter =
context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)
? ((int)retryAfter.TotalSeconds).ToString()
: "60";
await context.HttpContext.Response.WriteAsJsonAsync(new
{
error = "rate_limit_exceeded",
message = "Too many requests. Please retry after the specified time.",
retry_after_seconds = retryAfter?.TotalSeconds ?? 60
}, token);
};
});
return services;
}
}
Applying Rate Limits to Controllers
// File: src/POS.Api/Controllers/PaymentsController.cs
[ApiController]
[Route("api/v1/payments")]
[EnableRateLimiting("payments")] // Apply payments rate limit
public class PaymentsController : ControllerBase
{
[HttpPost]
[EnableRateLimiting("payments")]
public async Task<IActionResult> ProcessPayment(
[FromBody] PaymentRequest request)
{
// Rate limited to 10/minute per tenant
return Ok(await _paymentService.ProcessAsync(request));
}
}
[ApiController]
[Route("api/v1/reports")]
[EnableRateLimiting("reports")]
public class ReportsController : ControllerBase
{
[HttpGet("sales")]
public async Task<IActionResult> GetSalesReport(
[FromQuery] DateRange range)
{
// Rate limited to 5/minute per tenant
return Ok(await _reportService.GenerateSalesReport(range));
}
}
Outbound Rate Limiting for External APIs
// File: src/POS.Infrastructure/Http/OutboundRateLimiter.cs
public class ShopifyRateLimitedClient : IShopifyClient
{
private readonly HttpClient _httpClient;
private readonly RateLimiter _rateLimiter;
private readonly ILogger _logger;
public ShopifyRateLimitedClient(HttpClient httpClient, ILogger<ShopifyRateLimitedClient> logger)
{
_httpClient = httpClient;
_logger = logger;
// Shopify allows 40 requests per second per store
// Use sliding window to smooth out bursts
_rateLimiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions
{
PermitLimit = 40,
Window = TimeSpan.FromSeconds(1),
SegmentsPerWindow = 4, // 250ms segments
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 100 // Queue excess requests
});
}
public async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken ct)
{
using var lease = await _rateLimiter.AcquireAsync(1, ct);
if (!lease.IsAcquired)
{
_logger.LogWarning("Shopify rate limit exceeded. Request queued.");
throw new RateLimitExceededException("Shopify API rate limit exceeded");
}
var response = await _httpClient.SendAsync(request, ct);
// Check Shopify's rate limit headers
if (response.Headers.TryGetValues("X-Shopify-Shop-Api-Call-Limit", out var values))
{
var callLimit = values.First(); // e.g., "35/40"
var parts = callLimit.Split('/');
var current = int.Parse(parts[0]);
var max = int.Parse(parts[1]);
if (current > max * 0.8) // 80% threshold
{
_logger.LogWarning(
"Shopify API approaching limit: {Current}/{Max}",
current, max);
}
}
return response;
}
}
Rate Limit Headers in Responses
// File: src/POS.Api/Middleware/RateLimitHeaderMiddleware.cs
public class RateLimitHeaderMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
await next(context);
// Add rate limit headers to response
if (context.Features.Get<IRateLimitFeature>() is { } feature)
{
context.Response.Headers["X-RateLimit-Limit"] = feature.Limit.ToString();
context.Response.Headers["X-RateLimit-Remaining"] = feature.Remaining.ToString();
context.Response.Headers["X-RateLimit-Reset"] = feature.Reset.ToUnixTimeSeconds().ToString();
}
}
}
// Response example:
// HTTP/1.1 200 OK
// X-RateLimit-Limit: 100
// X-RateLimit-Remaining: 87
// X-RateLimit-Reset: 1706140800
13.4.3 Basic Retry Configuration (Legacy Reference)
For simpler scenarios without full Polly v8 pipeline:
// File: src/POS.Infrastructure/Http/HttpClientConfiguration.cs
using Microsoft.Extensions.DependencyInjection;
using Polly;
using Polly.Extensions.Http;
namespace POS.Infrastructure.Http;
public static class HttpClientConfiguration
{
public static IServiceCollection AddExternalApiClients(
this IServiceCollection services)
{
// Shopify client with retry
services.AddHttpClient<IShopifyClient, ShopifyClient>()
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());
// Payment clients
services.AddHttpClient<IStripeClient, StripeClient>()
.AddPolicyHandler(GetRetryPolicy());
services.AddHttpClient<ISquareClient, SquareClient>()
.AddPolicyHandler(GetRetryPolicy());
return services;
}
private static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(3, retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
}
private static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));
}
}
13.4.4 Credential Management
// File: src/POS.Infrastructure/Security/CredentialService.cs
using Microsoft.Extensions.Caching.Memory;
using Azure.Security.KeyVault.Secrets;
namespace POS.Infrastructure.Security;
public class CredentialService : IPaymentCredentialService, IShopifyCredentialService
{
private readonly IIntegrationCredentialRepository _repository;
private readonly SecretClient? _keyVaultClient;
private readonly IMemoryCache _cache;
private readonly ILogger<CredentialService> _logger;
public async Task<StripeCredentials?> GetStripeCredentialsAsync(
string tenantId,
CancellationToken ct)
{
var cacheKey = $"stripe:{tenantId}";
if (_cache.TryGetValue(cacheKey, out StripeCredentials? cached))
return cached;
var integration = await _repository.GetByTypeAsync(
tenantId, IntegrationType.Stripe, ct);
if (integration is null)
return null;
// Decrypt secret key from Key Vault or encrypted storage
var secretKey = _keyVaultClient is not null
? (await _keyVaultClient.GetSecretAsync(
$"stripe-{tenantId}", cancellationToken: ct)).Value.Value
: DecryptSecret(integration.EncryptedSecretKey);
var credentials = new StripeCredentials
{
PublishableKey = integration.PublicKey,
SecretKey = secretKey,
WebhookSecret = integration.WebhookSecret
};
_cache.Set(cacheKey, credentials, TimeSpan.FromMinutes(15));
return credentials;
}
public async Task<ShopifyCredentials?> GetCredentialsAsync(
string tenantId,
CancellationToken ct)
{
var cacheKey = $"shopify:{tenantId}";
if (_cache.TryGetValue(cacheKey, out ShopifyCredentials? cached))
return cached;
var integration = await _repository.GetByTypeAsync(
tenantId, IntegrationType.Shopify, ct);
if (integration is null)
return null;
var accessToken = _keyVaultClient is not null
? (await _keyVaultClient.GetSecretAsync(
$"shopify-{tenantId}", cancellationToken: ct)).Value.Value
: DecryptSecret(integration.EncryptedSecretKey);
var credentials = new ShopifyCredentials
{
ShopDomain = integration.ExternalId,
AccessToken = accessToken,
WebhookSecret = integration.WebhookSecret
};
_cache.Set(cacheKey, credentials, TimeSpan.FromMinutes(15));
return credentials;
}
private static string DecryptSecret(string encryptedValue)
{
// Implementation depends on encryption strategy
// Could use DPAPI, AES, etc.
throw new NotImplementedException(
"Implement based on your encryption strategy");
}
}
13.5 Integration Error Codes (ERR-6xxx)
All integration-related errors use the ERR-6xxx range per the BRD error code convention. See Chapter 05 (Architecture Components) for the full Module 6: Integrations & External Systems architecture, including Amazon SP-API, Google Merchant API, and enhanced Shopify patterns.
| Code 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
- Credential management: Secure storage with caching
- Integration error codes: ERR-6xxx range covering all external system failures
See also: Chapter 05 (Architecture Components) defines the complete Module 6: Integrations & External Systems architecture with Amazon SP-API, Google Merchant API, enhanced Shopify integration, and the strictest-rule-wins cross-platform validation strategy.
Next: Part V: Frontend - Chapter 14: POS Client covers frontend implementation with the POS Client application.
Document Information
| 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.