Chapter 18: Integration Patterns
Shopify, Payment Processing, and External API Integration
This chapter provides complete implementation patterns for integrating with Shopify, payment processors (Stripe/Square), and external APIs with PCI-DSS compliance.
18.1 Integration Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ POS Platform │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ Shopify │ │ Payment │ │ Other Integrations │ │
│ │ Integration │ │ Processing │ │ (Accounting, etc.) │ │
│ └────────┬────────┘ └────────┬────────┘ └───────────┬─────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Integration Service Layer │ │
│ │ • Webhook handlers • Payment abstraction │ │
│ │ • Retry logic • Token management │ │
│ │ • Event publishing • Audit logging │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Shopify │ │ Stripe API │ │ QuickBooks │
│ Admin API │ │ Square API │ │ Online │
└───────────────┘ └───────────────┘ └───────────────┘
18.2 Shopify Integration
18.2.1 Webhook Configuration
// File: src/POS.Infrastructure/Integrations/Shopify/ShopifyWebhookConfig.cs
namespace POS.Infrastructure.Integrations.Shopify;
public static class ShopifyWebhookTopics
{
// Order webhooks
public const string OrdersCreate = "orders/create";
public const string OrdersUpdated = "orders/updated";
public const string OrdersCancelled = "orders/cancelled";
public const string OrdersFulfilled = "orders/fulfilled";
public const string OrdersPaid = "orders/paid";
// Inventory webhooks
public const string InventoryLevelsUpdate = "inventory_levels/update";
public const string InventoryLevelsConnect = "inventory_levels/connect";
public const string InventoryLevelsDisconnect = "inventory_levels/disconnect";
// Product webhooks
public const string ProductsCreate = "products/create";
public const string ProductsUpdate = "products/update";
public const string ProductsDelete = "products/delete";
// Customer webhooks
public const string CustomersCreate = "customers/create";
public const string CustomersUpdate = "customers/update";
// Refund webhooks
public const string RefundsCreate = "refunds/create";
}
18.2.2 Webhook Controller
// File: src/POS.Api/Controllers/ShopifyWebhookController.cs
using Microsoft.AspNetCore.Mvc;
using System.Security.Cryptography;
using System.Text;
namespace POS.Api.Controllers;
[ApiController]
[Route("api/v1/webhooks/shopify")]
public class ShopifyWebhookController : ControllerBase
{
private readonly IShopifyWebhookHandler _webhookHandler;
private readonly IShopifyCredentialService _credentialService;
private readonly ILogger<ShopifyWebhookController> _logger;
public ShopifyWebhookController(
IShopifyWebhookHandler webhookHandler,
IShopifyCredentialService credentialService,
ILogger<ShopifyWebhookController> logger)
{
_webhookHandler = webhookHandler;
_credentialService = credentialService;
_logger = logger;
}
[HttpPost("{tenantId}")]
public async Task<IActionResult> HandleWebhook(
string tenantId,
CancellationToken ct)
{
// Read raw body for HMAC verification
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var rawBody = await reader.ReadToEndAsync();
Request.Body.Position = 0;
// Verify HMAC signature
var hmacHeader = Request.Headers["X-Shopify-Hmac-Sha256"].FirstOrDefault();
if (string.IsNullOrEmpty(hmacHeader))
{
_logger.LogWarning("Missing HMAC header for tenant {TenantId}", tenantId);
return Unauthorized();
}
var credentials = await _credentialService.GetCredentialsAsync(tenantId, ct);
if (credentials is null)
{
_logger.LogWarning("No Shopify credentials for tenant {TenantId}", tenantId);
return NotFound();
}
if (!VerifyHmac(rawBody, hmacHeader, credentials.WebhookSecret))
{
_logger.LogWarning("Invalid HMAC for tenant {TenantId}", tenantId);
return Unauthorized();
}
// Extract webhook topic
var topic = Request.Headers["X-Shopify-Topic"].FirstOrDefault();
var shopDomain = Request.Headers["X-Shopify-Shop-Domain"].FirstOrDefault();
var webhookId = Request.Headers["X-Shopify-Webhook-Id"].FirstOrDefault();
_logger.LogInformation(
"Received Shopify webhook {Topic} from {Shop} for tenant {TenantId}",
topic, shopDomain, tenantId);
// Queue for processing (respond quickly to Shopify)
await _webhookHandler.QueueWebhookAsync(new ShopifyWebhookEvent
{
TenantId = tenantId,
Topic = topic!,
ShopDomain = shopDomain!,
WebhookId = webhookId!,
Payload = rawBody,
ReceivedAt = DateTime.UtcNow
}, ct);
return Ok();
}
private static bool VerifyHmac(string body, string hmacHeader, string secret)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
var computedHmac = Convert.ToBase64String(hash);
return hmacHeader == computedHmac;
}
}
18.2.3 Webhook Handler Implementation
// File: src/POS.Infrastructure/Integrations/Shopify/ShopifyWebhookHandler.cs
using System.Text.Json;
using MassTransit;
namespace POS.Infrastructure.Integrations.Shopify;
public class ShopifyWebhookHandler : IShopifyWebhookHandler
{
private readonly IPublishEndpoint _publishEndpoint;
private readonly IInventoryService _inventoryService;
private readonly IOrderService _orderService;
private readonly IItemService _itemService;
private readonly ITenantContext _tenantContext;
private readonly ILogger<ShopifyWebhookHandler> _logger;
public ShopifyWebhookHandler(
IPublishEndpoint publishEndpoint,
IInventoryService inventoryService,
IOrderService orderService,
IItemService itemService,
ITenantContext tenantContext,
ILogger<ShopifyWebhookHandler> logger)
{
_publishEndpoint = publishEndpoint;
_inventoryService = inventoryService;
_orderService = orderService;
_itemService = itemService;
_tenantContext = tenantContext;
_logger = logger;
}
public async Task QueueWebhookAsync(
ShopifyWebhookEvent webhook,
CancellationToken ct)
{
// Publish to message queue for async processing
await _publishEndpoint.Publish(webhook, ct);
}
public async Task ProcessWebhookAsync(
ShopifyWebhookEvent webhook,
CancellationToken ct)
{
_tenantContext.SetTenant(webhook.TenantId);
try
{
switch (webhook.Topic)
{
case ShopifyWebhookTopics.OrdersCreate:
await HandleOrderCreatedAsync(webhook.Payload, ct);
break;
case ShopifyWebhookTopics.OrdersUpdated:
await HandleOrderUpdatedAsync(webhook.Payload, ct);
break;
case ShopifyWebhookTopics.OrdersCancelled:
await HandleOrderCancelledAsync(webhook.Payload, ct);
break;
case ShopifyWebhookTopics.InventoryLevelsUpdate:
await HandleInventoryUpdateAsync(webhook.Payload, ct);
break;
case ShopifyWebhookTopics.ProductsCreate:
case ShopifyWebhookTopics.ProductsUpdate:
await HandleProductUpdateAsync(webhook.Payload, ct);
break;
case ShopifyWebhookTopics.ProductsDelete:
await HandleProductDeleteAsync(webhook.Payload, ct);
break;
default:
_logger.LogWarning(
"Unhandled webhook topic: {Topic}",
webhook.Topic);
break;
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error processing webhook {Topic} for tenant {TenantId}",
webhook.Topic, webhook.TenantId);
throw;
}
}
private async Task HandleOrderCreatedAsync(string payload, CancellationToken ct)
{
var order = JsonSerializer.Deserialize<ShopifyOrder>(payload,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (order is null) return;
_logger.LogInformation(
"Processing Shopify order {OrderNumber} ({OrderId})",
order.OrderNumber, order.Id);
// Import order to POS system
var importResult = await _orderService.ImportShopifyOrderAsync(
new ImportShopifyOrderCommand
{
ShopifyOrderId = order.Id.ToString(),
OrderNumber = order.OrderNumber,
CustomerEmail = order.Email,
CustomerName = $"{order.Customer?.FirstName} {order.Customer?.LastName}",
TotalPrice = order.TotalPrice,
Currency = order.Currency,
LineItems = order.LineItems.Select(li => new ImportedLineItem
{
ShopifyLineItemId = li.Id.ToString(),
Sku = li.Sku,
Title = li.Title,
Quantity = li.Quantity,
Price = li.Price,
VariantId = li.VariantId?.ToString()
}).ToList(),
FulfillmentStatus = order.FulfillmentStatus,
FinancialStatus = order.FinancialStatus,
ShippingAddress = order.ShippingAddress != null
? new AddressDto
{
Address1 = order.ShippingAddress.Address1,
Address2 = order.ShippingAddress.Address2,
City = order.ShippingAddress.City,
Province = order.ShippingAddress.Province,
Zip = order.ShippingAddress.Zip,
Country = order.ShippingAddress.Country
}
: null,
CreatedAt = order.CreatedAt
}, ct);
if (!importResult.IsSuccess)
{
_logger.LogError(
"Failed to import Shopify order {OrderNumber}: {Error}",
order.OrderNumber, importResult.Error?.Message);
}
}
private async Task HandleInventoryUpdateAsync(string payload, CancellationToken ct)
{
var update = JsonSerializer.Deserialize<ShopifyInventoryLevel>(payload,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (update is null) return;
_logger.LogInformation(
"Processing inventory update for variant {InventoryItemId} at location {LocationId}",
update.InventoryItemId, update.LocationId);
// Find item by Shopify inventory item ID
var item = await _itemService.GetByShopifyInventoryItemIdAsync(
update.InventoryItemId.ToString(), ct);
if (item is null)
{
_logger.LogWarning(
"Item not found for Shopify inventory item {InventoryItemId}",
update.InventoryItemId);
return;
}
// Find POS location by Shopify location ID
var location = await _inventoryService.GetLocationByShopifyIdAsync(
update.LocationId.ToString(), ct);
if (location is null)
{
_logger.LogWarning(
"Location not found for Shopify location {LocationId}",
update.LocationId);
return;
}
// Update inventory (from Shopify, not triggering sync back)
await _inventoryService.SyncFromShopifyAsync(
new SyncInventoryCommand
{
ItemId = item.Id,
LocationId = location.Id,
Quantity = update.Available,
Source = "shopify_webhook",
ShopifyUpdatedAt = update.UpdatedAt
}, ct);
}
private async Task HandleProductUpdateAsync(string payload, CancellationToken ct)
{
var product = JsonSerializer.Deserialize<ShopifyProduct>(payload,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (product is null) return;
_logger.LogInformation(
"Processing product update for {ProductTitle} ({ProductId})",
product.Title, product.Id);
foreach (var variant in product.Variants)
{
var existingItem = await _itemService.GetByShopifyVariantIdAsync(
variant.Id.ToString(), ct);
if (existingItem is not null)
{
// Update existing item
await _itemService.UpdateFromShopifyAsync(
existingItem.Id,
new UpdateFromShopifyCommand
{
Name = $"{product.Title} - {variant.Title}",
Sku = variant.Sku,
Barcode = variant.Barcode,
Price = variant.Price,
CompareAtPrice = variant.CompareAtPrice,
Weight = variant.Weight,
WeightUnit = variant.WeightUnit
}, ct);
}
else
{
_logger.LogInformation(
"New Shopify variant {VariantId} not linked to POS item",
variant.Id);
}
}
}
private async Task HandleOrderCancelledAsync(string payload, CancellationToken ct)
{
var order = JsonSerializer.Deserialize<ShopifyOrder>(payload,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (order is null) return;
await _orderService.CancelShopifyOrderAsync(order.Id.ToString(), ct);
}
private async Task HandleProductDeleteAsync(string payload, CancellationToken ct)
{
var deleteEvent = JsonSerializer.Deserialize<ShopifyProductDelete>(payload,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (deleteEvent is null) return;
_logger.LogInformation(
"Shopify product {ProductId} deleted - marking POS items as inactive",
deleteEvent.Id);
await _itemService.DeactivateByShopifyProductIdAsync(
deleteEvent.Id.ToString(), ct);
}
}
18.2.4 Shopify API Client
// File: src/POS.Infrastructure/Integrations/Shopify/ShopifyClient.cs
using System.Net.Http.Json;
using System.Text.Json;
namespace POS.Infrastructure.Integrations.Shopify;
public class ShopifyClient : IShopifyClient
{
private readonly HttpClient _httpClient;
private readonly IShopifyCredentialService _credentialService;
private readonly ILogger<ShopifyClient> _logger;
public ShopifyClient(
HttpClient httpClient,
IShopifyCredentialService credentialService,
ILogger<ShopifyClient> logger)
{
_httpClient = httpClient;
_credentialService = credentialService;
_logger = logger;
}
public async Task<bool> UpdateInventoryLevelAsync(
string tenantId,
string inventoryItemId,
string locationId,
int quantity,
CancellationToken ct)
{
var credentials = await _credentialService.GetCredentialsAsync(tenantId, ct);
if (credentials is null)
throw new InvalidOperationException($"No Shopify credentials for tenant {tenantId}");
var baseUrl = $"https://{credentials.ShopDomain}/admin/api/2024-01";
var request = new HttpRequestMessage(HttpMethod.Post,
$"{baseUrl}/inventory_levels/set.json");
request.Headers.Add("X-Shopify-Access-Token", credentials.AccessToken);
request.Content = JsonContent.Create(new
{
inventory_item_id = long.Parse(inventoryItemId),
location_id = long.Parse(locationId),
available = quantity
});
var response = await _httpClient.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(ct);
_logger.LogError(
"Failed to update Shopify inventory: {StatusCode} - {Error}",
response.StatusCode, error);
return false;
}
return true;
}
public async Task<bool> FulfillOrderAsync(
string tenantId,
string orderId,
string locationId,
IEnumerable<FulfillmentLineItem> lineItems,
string? trackingNumber,
string? trackingCompany,
CancellationToken ct)
{
var credentials = await _credentialService.GetCredentialsAsync(tenantId, ct);
if (credentials is null)
throw new InvalidOperationException($"No Shopify credentials for tenant {tenantId}");
var baseUrl = $"https://{credentials.ShopDomain}/admin/api/2024-01";
// First, get fulfillment order
var fulfillmentOrderRequest = new HttpRequestMessage(HttpMethod.Get,
$"{baseUrl}/orders/{orderId}/fulfillment_orders.json");
fulfillmentOrderRequest.Headers.Add("X-Shopify-Access-Token", credentials.AccessToken);
var foResponse = await _httpClient.SendAsync(fulfillmentOrderRequest, ct);
if (!foResponse.IsSuccessStatusCode)
{
_logger.LogError("Failed to get fulfillment orders for order {OrderId}", orderId);
return false;
}
var foResult = await foResponse.Content.ReadFromJsonAsync<FulfillmentOrdersResponse>(ct);
var fulfillmentOrder = foResult?.FulfillmentOrders?.FirstOrDefault();
if (fulfillmentOrder is null)
{
_logger.LogWarning("No fulfillment order found for order {OrderId}", orderId);
return false;
}
// Create fulfillment
var fulfillmentRequest = new HttpRequestMessage(HttpMethod.Post,
$"{baseUrl}/fulfillments.json");
fulfillmentRequest.Headers.Add("X-Shopify-Access-Token", credentials.AccessToken);
var fulfillmentPayload = new
{
fulfillment = new
{
line_items_by_fulfillment_order = new[]
{
new
{
fulfillment_order_id = fulfillmentOrder.Id,
fulfillment_order_line_items = lineItems.Select(li => new
{
id = li.FulfillmentOrderLineItemId,
quantity = li.Quantity
}).ToArray()
}
},
tracking_info = !string.IsNullOrEmpty(trackingNumber) ? new
{
number = trackingNumber,
company = trackingCompany
} : null,
notify_customer = true
}
};
fulfillmentRequest.Content = JsonContent.Create(fulfillmentPayload);
var response = await _httpClient.SendAsync(fulfillmentRequest, ct);
if (!response.IsSuccessStatusCode)
{
var error = await response.Content.ReadAsStringAsync(ct);
_logger.LogError(
"Failed to create Shopify fulfillment: {StatusCode} - {Error}",
response.StatusCode, error);
return false;
}
return true;
}
}
18.3 Payment Processing
18.3.1 PCI-DSS Compliance Pattern
┌─────────────────────────────────────────────────────────────────────────────┐
│ PCI-DSS Compliant Payment Flow │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Card Data NEVER touches POS server │
│ 2. Use payment terminal or tokenization │
│ 3. Only store payment tokens │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Customer │ │ Payment │ │ Payment │ │
│ │ Card │───────►│ Terminal │───────►│ Processor │ │
│ └─────────────┘ └─────────────┘ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ POS │◄───────│ Token + │◄───────│ Response │ │
│ │ Server │ │ Last 4 │ │ (Success) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ Stored: payment_token, card_last_4, card_brand │
│ NOT Stored: card_number, cvv, expiry │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
18.3.2 Payment Service Interface
// File: src/POS.Application/Interfaces/IPaymentService.cs
namespace POS.Application.Interfaces;
public interface IPaymentService
{
Task<Result<PaymentResult>> ProcessPaymentAsync(
ProcessPaymentCommand command,
CancellationToken ct = default);
Task<Result<RefundResult>> ProcessRefundAsync(
ProcessRefundCommand command,
CancellationToken ct = default);
Task<Result> VoidPaymentAsync(
string transactionId,
CancellationToken ct = default);
Task<PaymentMethodsResult> GetAvailableMethodsAsync(
string locationId,
CancellationToken ct = default);
// Terminal operations
Task<TerminalStatus> GetTerminalStatusAsync(
string terminalId,
CancellationToken ct = default);
Task<Result<TerminalPaymentIntent>> CreateTerminalPaymentIntentAsync(
CreateTerminalPaymentCommand command,
CancellationToken ct = default);
Task<Result<PaymentResult>> CaptureTerminalPaymentAsync(
string paymentIntentId,
CancellationToken ct = default);
Task<Result> CancelTerminalPaymentAsync(
string paymentIntentId,
CancellationToken ct = default);
}
18.3.3 Stripe Terminal Integration
// File: src/POS.Infrastructure/Payments/StripePaymentService.cs
using Stripe;
using Stripe.Terminal;
namespace POS.Infrastructure.Payments;
public class StripePaymentService : IPaymentService
{
private readonly IPaymentCredentialService _credentialService;
private readonly ITenantContext _tenantContext;
private readonly IAuditLogger _auditLogger;
private readonly ILogger<StripePaymentService> _logger;
public StripePaymentService(
IPaymentCredentialService credentialService,
ITenantContext tenantContext,
IAuditLogger auditLogger,
ILogger<StripePaymentService> logger)
{
_credentialService = credentialService;
_tenantContext = tenantContext;
_auditLogger = auditLogger;
_logger = logger;
}
public async Task<Result<PaymentResult>> ProcessPaymentAsync(
ProcessPaymentCommand command,
CancellationToken ct = default)
{
var credentials = await _credentialService.GetStripeCredentialsAsync(
_tenantContext.TenantId!, ct);
if (credentials is null)
return Result<PaymentResult>.Failure(
DomainError.PaymentNotConfigured("Stripe"));
StripeConfiguration.ApiKey = credentials.SecretKey;
try
{
switch (command.Method)
{
case PaymentMethod.CreditCard when command.TerminalId is not null:
return await ProcessTerminalPaymentAsync(command, ct);
case PaymentMethod.CreditCard when command.PaymentToken is not null:
return await ProcessTokenPaymentAsync(command, ct);
case PaymentMethod.Cash:
return await ProcessCashPaymentAsync(command, ct);
default:
return Result<PaymentResult>.Failure(
DomainError.InvalidPaymentMethod(command.Method.ToString()));
}
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe payment failed: {Code}", ex.StripeError?.Code);
return Result<PaymentResult>.Failure(
DomainError.PaymentFailed(ex.StripeError?.Message ?? ex.Message));
}
}
private async Task<Result<PaymentResult>> ProcessTerminalPaymentAsync(
ProcessPaymentCommand command,
CancellationToken ct)
{
_logger.LogInformation(
"Processing terminal payment of {Amount} on terminal {TerminalId}",
command.Amount, command.TerminalId);
// Create PaymentIntent
var paymentIntentService = new PaymentIntentService();
var paymentIntent = await paymentIntentService.CreateAsync(
new PaymentIntentCreateOptions
{
Amount = (long)(command.Amount * 100), // Convert to cents
Currency = "usd",
PaymentMethodTypes = new List<string> { "card_present" },
CaptureMethod = "automatic",
Metadata = new Dictionary<string, string>
{
["order_id"] = command.OrderId,
["tenant_id"] = _tenantContext.TenantId!,
["location_id"] = command.LocationId
}
}, cancellationToken: ct);
// Process on terminal
var readerService = new ReaderService();
var processResult = await readerService.ProcessPaymentIntentAsync(
command.TerminalId,
new ReaderProcessPaymentIntentOptions
{
PaymentIntent = paymentIntent.Id
}, cancellationToken: ct);
// Wait for payment to complete (simplified - real impl would poll)
var updatedIntent = await WaitForPaymentCompletionAsync(
paymentIntent.Id, TimeSpan.FromSeconds(60), ct);
if (updatedIntent.Status != "succeeded")
{
return Result<PaymentResult>.Failure(
DomainError.PaymentFailed(
$"Terminal payment failed with status: {updatedIntent.Status}"));
}
var charge = updatedIntent.LatestCharge;
await _auditLogger.LogAsync(new AuditEvent
{
TenantId = _tenantContext.TenantId!,
EventType = "PaymentProcessed",
Details = $"Card payment {command.Amount:C} via terminal {command.TerminalId}",
ReferenceId = paymentIntent.Id,
ReferenceType = "StripePaymentIntent"
}, ct);
return Result<PaymentResult>.Success(new PaymentResult
{
Success = true,
TransactionId = paymentIntent.Id,
ChargeId = charge?.Id,
Amount = command.Amount,
CardLast4 = charge?.PaymentMethodDetails?.CardPresent?.Last4,
CardBrand = charge?.PaymentMethodDetails?.CardPresent?.Brand,
AuthorizationCode = charge?.AuthorizationCode
});
}
private async Task<Result<PaymentResult>> ProcessTokenPaymentAsync(
ProcessPaymentCommand command,
CancellationToken ct)
{
_logger.LogInformation(
"Processing token payment of {Amount}",
command.Amount);
var paymentIntentService = new PaymentIntentService();
var paymentIntent = await paymentIntentService.CreateAsync(
new PaymentIntentCreateOptions
{
Amount = (long)(command.Amount * 100),
Currency = "usd",
PaymentMethod = command.PaymentToken,
Confirm = true,
Metadata = new Dictionary<string, string>
{
["order_id"] = command.OrderId,
["tenant_id"] = _tenantContext.TenantId!
}
}, cancellationToken: ct);
if (paymentIntent.Status != "succeeded")
{
return Result<PaymentResult>.Failure(
DomainError.PaymentFailed(
$"Payment failed with status: {paymentIntent.Status}"));
}
var charge = paymentIntent.LatestCharge;
return Result<PaymentResult>.Success(new PaymentResult
{
Success = true,
TransactionId = paymentIntent.Id,
ChargeId = charge?.Id,
Amount = command.Amount,
CardLast4 = charge?.PaymentMethodDetails?.Card?.Last4,
CardBrand = charge?.PaymentMethodDetails?.Card?.Brand
});
}
private Task<Result<PaymentResult>> ProcessCashPaymentAsync(
ProcessPaymentCommand command,
CancellationToken ct)
{
// Cash payments don't need external processing
var transactionId = $"CASH-{Guid.NewGuid():N}"[..24];
_logger.LogInformation(
"Recording cash payment of {Amount}",
command.Amount);
return Task.FromResult(Result<PaymentResult>.Success(new PaymentResult
{
Success = true,
TransactionId = transactionId,
Amount = command.Amount
}));
}
public async Task<Result<RefundResult>> ProcessRefundAsync(
ProcessRefundCommand command,
CancellationToken ct = default)
{
var credentials = await _credentialService.GetStripeCredentialsAsync(
_tenantContext.TenantId!, ct);
if (credentials is null)
return Result<RefundResult>.Failure(
DomainError.PaymentNotConfigured("Stripe"));
StripeConfiguration.ApiKey = credentials.SecretKey;
try
{
var refundService = new RefundService();
var refund = await refundService.CreateAsync(
new RefundCreateOptions
{
PaymentIntent = command.OriginalTransactionId,
Amount = (long)(command.Amount * 100),
Reason = MapRefundReason(command.Reason),
Metadata = new Dictionary<string, string>
{
["refund_order_id"] = command.RefundOrderId,
["original_order_id"] = command.OriginalOrderId
}
}, cancellationToken: ct);
await _auditLogger.LogAsync(new AuditEvent
{
TenantId = _tenantContext.TenantId!,
EventType = "RefundProcessed",
Details = $"Refund {command.Amount:C} for order {command.OriginalOrderId}",
ReferenceId = refund.Id,
ReferenceType = "StripeRefund"
}, ct);
return Result<RefundResult>.Success(new RefundResult
{
Success = true,
TransactionId = refund.Id,
Amount = command.Amount,
Status = refund.Status
});
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe refund failed: {Code}", ex.StripeError?.Code);
return Result<RefundResult>.Failure(
DomainError.RefundFailed(ex.StripeError?.Message ?? ex.Message));
}
}
public async Task<Result> VoidPaymentAsync(
string transactionId,
CancellationToken ct = default)
{
var credentials = await _credentialService.GetStripeCredentialsAsync(
_tenantContext.TenantId!, ct);
if (credentials is null)
return Result.Failure(DomainError.PaymentNotConfigured("Stripe"));
StripeConfiguration.ApiKey = credentials.SecretKey;
try
{
var paymentIntentService = new PaymentIntentService();
await paymentIntentService.CancelAsync(transactionId, cancellationToken: ct);
await _auditLogger.LogAsync(new AuditEvent
{
TenantId = _tenantContext.TenantId!,
EventType = "PaymentVoided",
ReferenceId = transactionId,
ReferenceType = "StripePaymentIntent"
}, ct);
return Result.Success();
}
catch (StripeException ex) when (ex.StripeError?.Code == "payment_intent_unexpected_state")
{
// Already captured - need to refund instead
var refundService = new RefundService();
await refundService.CreateAsync(
new RefundCreateOptions { PaymentIntent = transactionId },
cancellationToken: ct);
return Result.Success();
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe void failed: {Code}", ex.StripeError?.Code);
return Result.Failure(
DomainError.VoidFailed(ex.StripeError?.Message ?? ex.Message));
}
}
private async Task<PaymentIntent> WaitForPaymentCompletionAsync(
string paymentIntentId,
TimeSpan timeout,
CancellationToken ct)
{
var paymentIntentService = new PaymentIntentService();
var startTime = DateTime.UtcNow;
while (DateTime.UtcNow - startTime < timeout)
{
var intent = await paymentIntentService.GetAsync(
paymentIntentId, cancellationToken: ct);
if (intent.Status is "succeeded" or "canceled" or "requires_payment_method")
{
return intent;
}
await Task.Delay(1000, ct);
}
throw new TimeoutException("Payment processing timed out");
}
private static string MapRefundReason(RefundReason reason) => reason switch
{
RefundReason.CustomerRequest => "requested_by_customer",
RefundReason.Duplicate => "duplicate",
RefundReason.Fraudulent => "fraudulent",
_ => "requested_by_customer"
};
}
18.3.4 Square Integration Pattern
// File: src/POS.Infrastructure/Payments/SquarePaymentService.cs
using Square;
using Square.Models;
namespace POS.Infrastructure.Payments;
public class SquarePaymentService : IPaymentService
{
private readonly IPaymentCredentialService _credentialService;
private readonly ITenantContext _tenantContext;
private readonly ILogger<SquarePaymentService> _logger;
public SquarePaymentService(
IPaymentCredentialService credentialService,
ITenantContext tenantContext,
ILogger<SquarePaymentService> logger)
{
_credentialService = credentialService;
_tenantContext = tenantContext;
_logger = logger;
}
public async Task<Result<PaymentResult>> ProcessPaymentAsync(
ProcessPaymentCommand command,
CancellationToken ct = default)
{
var credentials = await _credentialService.GetSquareCredentialsAsync(
_tenantContext.TenantId!, ct);
if (credentials is null)
return Result<PaymentResult>.Failure(
DomainError.PaymentNotConfigured("Square"));
var client = new SquareClient.Builder()
.Environment(credentials.IsSandbox
? Square.Environment.Sandbox
: Square.Environment.Production)
.AccessToken(credentials.AccessToken)
.Build();
try
{
// Create terminal checkout for card-present
if (command.TerminalId is not null)
{
var checkoutRequest = new CreateTerminalCheckoutRequest.Builder(
Guid.NewGuid().ToString(),
new TerminalCheckout.Builder(
new Money.Builder()
.Amount((long)(command.Amount * 100))
.Currency("USD")
.Build(),
command.TerminalId)
.ReferenceId(command.OrderId)
.Build())
.Build();
var checkoutResponse = await client.TerminalApi.CreateTerminalCheckoutAsync(
checkoutRequest);
if (checkoutResponse.Errors?.Any() == true)
{
var error = checkoutResponse.Errors.First();
return Result<PaymentResult>.Failure(
DomainError.PaymentFailed(error.Detail));
}
var checkout = checkoutResponse.Checkout;
// Poll for completion
var completedCheckout = await WaitForCheckoutCompletionAsync(
client, checkout.Id, TimeSpan.FromSeconds(60), ct);
if (completedCheckout.Status != "COMPLETED")
{
return Result<PaymentResult>.Failure(
DomainError.PaymentFailed(
$"Checkout failed with status: {completedCheckout.Status}"));
}
return Result<PaymentResult>.Success(new PaymentResult
{
Success = true,
TransactionId = completedCheckout.PaymentIds?.FirstOrDefault(),
Amount = command.Amount,
CardLast4 = completedCheckout.CardDetails?.Card?.Last4,
CardBrand = completedCheckout.CardDetails?.Card?.CardBrand
});
}
else
{
// Token-based payment
var paymentRequest = new CreatePaymentRequest.Builder(
command.PaymentToken!,
Guid.NewGuid().ToString())
.AmountMoney(new Money.Builder()
.Amount((long)(command.Amount * 100))
.Currency("USD")
.Build())
.LocationId(credentials.LocationId)
.ReferenceId(command.OrderId)
.Build();
var paymentResponse = await client.PaymentsApi.CreatePaymentAsync(
paymentRequest);
if (paymentResponse.Errors?.Any() == true)
{
var error = paymentResponse.Errors.First();
return Result<PaymentResult>.Failure(
DomainError.PaymentFailed(error.Detail));
}
var payment = paymentResponse.Payment;
return Result<PaymentResult>.Success(new PaymentResult
{
Success = true,
TransactionId = payment.Id,
Amount = command.Amount,
CardLast4 = payment.CardDetails?.Card?.Last4,
CardBrand = payment.CardDetails?.Card?.CardBrand
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Square payment failed");
return Result<PaymentResult>.Failure(
DomainError.PaymentFailed(ex.Message));
}
}
private async Task<TerminalCheckout> WaitForCheckoutCompletionAsync(
SquareClient client,
string checkoutId,
TimeSpan timeout,
CancellationToken ct)
{
var startTime = DateTime.UtcNow;
while (DateTime.UtcNow - startTime < timeout)
{
var response = await client.TerminalApi.GetTerminalCheckoutAsync(checkoutId);
var checkout = response.Checkout;
if (checkout.Status is "COMPLETED" or "CANCELED")
{
return checkout;
}
await Task.Delay(1000, ct);
}
throw new TimeoutException("Checkout processing timed out");
}
// ... other interface methods
}
18.4 External API Patterns
18.4.1 Retry with Polly
// File: src/POS.Infrastructure/Http/HttpClientConfiguration.cs
using Microsoft.Extensions.DependencyInjection;
using Polly;
using Polly.Extensions.Http;
namespace POS.Infrastructure.Http;
public static class HttpClientConfiguration
{
public static IServiceCollection AddExternalApiClients(
this IServiceCollection services)
{
// Shopify client with retry
services.AddHttpClient<IShopifyClient, ShopifyClient>()
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());
// Payment clients
services.AddHttpClient<IStripeClient, StripeClient>()
.AddPolicyHandler(GetRetryPolicy());
services.AddHttpClient<ISquareClient, SquareClient>()
.AddPolicyHandler(GetRetryPolicy());
return services;
}
private static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(3, retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
}
private static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));
}
}
18.4.2 Credential Management
// File: src/POS.Infrastructure/Security/CredentialService.cs
using Microsoft.Extensions.Caching.Memory;
using Azure.Security.KeyVault.Secrets;
namespace POS.Infrastructure.Security;
public class CredentialService : IPaymentCredentialService, IShopifyCredentialService
{
private readonly IIntegrationCredentialRepository _repository;
private readonly SecretClient? _keyVaultClient;
private readonly IMemoryCache _cache;
private readonly ILogger<CredentialService> _logger;
public async Task<StripeCredentials?> GetStripeCredentialsAsync(
string tenantId,
CancellationToken ct)
{
var cacheKey = $"stripe:{tenantId}";
if (_cache.TryGetValue(cacheKey, out StripeCredentials? cached))
return cached;
var integration = await _repository.GetByTypeAsync(
tenantId, IntegrationType.Stripe, ct);
if (integration is null)
return null;
// Decrypt secret key from Key Vault or encrypted storage
var secretKey = _keyVaultClient is not null
? (await _keyVaultClient.GetSecretAsync(
$"stripe-{tenantId}", cancellationToken: ct)).Value.Value
: DecryptSecret(integration.EncryptedSecretKey);
var credentials = new StripeCredentials
{
PublishableKey = integration.PublicKey,
SecretKey = secretKey,
WebhookSecret = integration.WebhookSecret
};
_cache.Set(cacheKey, credentials, TimeSpan.FromMinutes(15));
return credentials;
}
public async Task<ShopifyCredentials?> GetCredentialsAsync(
string tenantId,
CancellationToken ct)
{
var cacheKey = $"shopify:{tenantId}";
if (_cache.TryGetValue(cacheKey, out ShopifyCredentials? cached))
return cached;
var integration = await _repository.GetByTypeAsync(
tenantId, IntegrationType.Shopify, ct);
if (integration is null)
return null;
var accessToken = _keyVaultClient is not null
? (await _keyVaultClient.GetSecretAsync(
$"shopify-{tenantId}", cancellationToken: ct)).Value.Value
: DecryptSecret(integration.EncryptedSecretKey);
var credentials = new ShopifyCredentials
{
ShopDomain = integration.ExternalId,
AccessToken = accessToken,
WebhookSecret = integration.WebhookSecret
};
_cache.Set(cacheKey, credentials, TimeSpan.FromMinutes(15));
return credentials;
}
private static string DecryptSecret(string encryptedValue)
{
// Implementation depends on encryption strategy
// Could use DPAPI, AES, etc.
throw new NotImplementedException(
"Implement based on your encryption strategy");
}
}
Summary
This chapter covered complete integration patterns:
- Shopify Integration: Webhooks for orders, inventory, and products with HMAC verification
- Payment Processing: PCI-DSS compliant patterns with Stripe Terminal and Square
- Token-only storage: Never store card numbers, only payment tokens
- External API resilience: Retry policies and circuit breakers with Polly
- Credential management: Secure storage with caching
Next: Part V covers frontend implementation with the POS Client application.