littleshop/LittleShop/Services/RoyalMailService.cs

605 lines
22 KiB
C#

using System.Text;
using System.Text.Json;
using LittleShop.DTOs;
using LittleShop.Models;
namespace LittleShop.Services;
public interface IRoyalMailService
{
Task<CreateShipmentResult> CreateShipmentAsync(CreateShipmentRequest request);
Task<TrackingResult> GetTrackingInfoAsync(string trackingNumber);
Task<List<RoyalMailServiceOption>> GetAvailableServicesAsync(decimal weight, string country);
Task<decimal> CalculateShippingCostAsync(decimal weight, string country, string serviceCode);
Task<byte[]?> GenerateShippingLabelAsync(string shipmentId);
Task<bool> CancelShipmentAsync(string shipmentId);
}
public class RoyalMailShippingService : IRoyalMailService
{
private readonly HttpClient _httpClient;
private readonly IConfiguration _configuration;
private readonly ILogger<RoyalMailShippingService> _logger;
private readonly bool _isProduction;
private string? _accessToken;
private DateTime _tokenExpiry;
public RoyalMailShippingService(
HttpClient httpClient,
IConfiguration configuration,
ILogger<RoyalMailShippingService> logger)
{
_httpClient = httpClient;
_configuration = configuration;
_logger = logger;
_isProduction = !string.IsNullOrEmpty(_configuration["RoyalMail:ClientId"]);
if (_isProduction)
{
_httpClient.BaseAddress = new Uri("https://api.royalmail.net/");
}
else
{
_logger.LogInformation("Royal Mail service running in development mode (API credentials not configured)");
}
}
public async Task<CreateShipmentResult> CreateShipmentAsync(CreateShipmentRequest request)
{
if (!_isProduction)
{
return CreateMockShipmentResult(request);
}
try
{
await EnsureAuthenticatedAsync();
var payload = new
{
shipmentType = "Delivery",
serviceCode = request.ServiceCode ?? "1st Class",
recipientName = request.RecipientName,
recipientAddress = new
{
addressLine1 = request.AddressLine1,
addressLine2 = request.AddressLine2,
city = request.City,
postCode = request.PostCode,
country = request.Country
},
senderAddress = new
{
addressLine1 = _configuration["RoyalMail:SenderAddress1"] ?? "123 Business St",
city = _configuration["RoyalMail:SenderCity"] ?? "London",
postCode = _configuration["RoyalMail:SenderPostCode"] ?? "SW1A 1AA",
country = "United Kingdom"
},
weight = request.WeightInGrams,
dimensions = new
{
length = request.Length ?? 20,
width = request.Width ?? 15,
height = request.Height ?? 5
},
value = request.Value,
currency = "GBP",
reference = request.Reference
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
_httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
var response = await _httpClient.PostAsync("shipping/v2/shipments", content);
var responseContent = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
var result = JsonSerializer.Deserialize<RoyalMailShipmentResponse>(responseContent, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
return new CreateShipmentResult
{
Success = true,
ShipmentId = result?.ShipmentId ?? Guid.NewGuid().ToString(),
TrackingNumber = result?.TrackingNumber ?? GenerateMockTrackingNumber(),
LabelUrl = result?.LabelUrl,
EstimatedDeliveryDate = DateTime.UtcNow.AddDays(2),
Cost = request.EstimatedCost ?? 5.50m
};
}
else
{
_logger.LogError("Royal Mail API error: {StatusCode} - {Response}",
response.StatusCode, responseContent);
return new CreateShipmentResult
{
Success = false,
ErrorMessage = $"Royal Mail API error: {response.StatusCode}"
};
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating Royal Mail shipment");
return new CreateShipmentResult
{
Success = false,
ErrorMessage = ex.Message
};
}
}
public async Task<TrackingResult> GetTrackingInfoAsync(string trackingNumber)
{
if (!_isProduction)
{
return CreateMockTrackingResult(trackingNumber);
}
try
{
await EnsureAuthenticatedAsync();
_httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
var response = await _httpClient.GetAsync($"tracking/v2/items/{trackingNumber}");
var responseContent = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
var result = JsonSerializer.Deserialize<RoyalMailTrackingResponse>(responseContent, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
return new TrackingResult
{
Success = true,
TrackingNumber = trackingNumber,
Status = result?.Status ?? "In Transit",
LastUpdate = result?.LastUpdate ?? DateTime.UtcNow,
EstimatedDelivery = result?.EstimatedDelivery,
TrackingEvents = result?.Events?.Select(e => new TrackingEvent
{
Timestamp = e.Timestamp,
Location = e.Location,
Description = e.Description,
Status = e.Status
}).ToList() ?? new List<TrackingEvent>()
};
}
else
{
return new TrackingResult
{
Success = false,
TrackingNumber = trackingNumber,
ErrorMessage = $"Tracking not found: {response.StatusCode}"
};
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting tracking info for {TrackingNumber}", trackingNumber);
return new TrackingResult
{
Success = false,
TrackingNumber = trackingNumber,
ErrorMessage = ex.Message
};
}
}
public async Task<List<RoyalMailServiceOption>> GetAvailableServicesAsync(decimal weight, string country)
{
if (!_isProduction)
{
return CreateMockServices(weight, country);
}
try
{
await EnsureAuthenticatedAsync();
var query = $"services?weight={weight}&country={country}";
_httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
var response = await _httpClient.GetAsync($"shipping/v2/{query}");
var responseContent = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
var services = JsonSerializer.Deserialize<List<RoyalMailShippingServiceResponse>>(responseContent, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
return services?.Select(s => new RoyalMailServiceOption
{
Code = s.Code,
Name = s.Name,
Description = s.Description,
Price = s.Price,
EstimatedDeliveryDays = s.EstimatedDeliveryDays,
MaxWeight = s.MaxWeight,
SupportsTracking = s.SupportsTracking
}).ToList() ?? new List<RoyalMailServiceOption>();
}
return new List<RoyalMailServiceOption>();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting Royal Mail services");
return CreateMockServices(weight, country);
}
}
public async Task<decimal> CalculateShippingCostAsync(decimal weight, string country, string serviceCode)
{
if (!_isProduction)
{
return CalculateMockShippingCost(weight, country, serviceCode);
}
var services = await GetAvailableServicesAsync(weight, country);
var service = services.FirstOrDefault(s => s.Code == serviceCode);
return service?.Price ?? 0m;
}
public async Task<byte[]?> GenerateShippingLabelAsync(string shipmentId)
{
if (!_isProduction)
{
return GenerateMockLabel(shipmentId);
}
try
{
await EnsureAuthenticatedAsync();
_httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
var response = await _httpClient.GetAsync($"shipping/v2/shipments/{shipmentId}/label");
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadAsByteArrayAsync();
}
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating shipping label for {ShipmentId}", shipmentId);
return null;
}
}
public async Task<bool> CancelShipmentAsync(string shipmentId)
{
if (!_isProduction)
{
_logger.LogInformation("Mock: Cancelled shipment {ShipmentId}", shipmentId);
return true;
}
try
{
await EnsureAuthenticatedAsync();
_httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
var response = await _httpClient.DeleteAsync($"shipping/v2/shipments/{shipmentId}");
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error cancelling shipment {ShipmentId}", shipmentId);
return false;
}
}
private async Task EnsureAuthenticatedAsync()
{
if (!string.IsNullOrEmpty(_accessToken) && DateTime.UtcNow < _tokenExpiry)
return;
var clientId = _configuration["RoyalMail:ClientId"];
var clientSecret = _configuration["RoyalMail:ClientSecret"];
if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret))
{
throw new InvalidOperationException("Royal Mail API credentials not configured");
}
var authPayload = new
{
grant_type = "client_credentials",
client_id = clientId,
client_secret = clientSecret
};
var json = JsonSerializer.Serialize(authPayload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync("oauth2/token", content);
var responseContent = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
var authResult = JsonSerializer.Deserialize<RoyalMailTokenResponse>(responseContent, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
_accessToken = authResult?.AccessToken;
_tokenExpiry = DateTime.UtcNow.AddSeconds(authResult?.ExpiresIn ?? 3600);
_logger.LogInformation("Successfully authenticated with Royal Mail API");
}
else
{
_logger.LogError("Failed to authenticate with Royal Mail API: {StatusCode} - {Response}",
response.StatusCode, responseContent);
throw new InvalidOperationException("Royal Mail API authentication failed");
}
}
// Mock methods for development
private CreateShipmentResult CreateMockShipmentResult(CreateShipmentRequest request)
{
var trackingNumber = GenerateMockTrackingNumber();
_logger.LogInformation("Mock: Created shipment for {Recipient} to {City}, {Country} - Tracking: {TrackingNumber}",
request.RecipientName, request.City, request.Country, trackingNumber);
return new CreateShipmentResult
{
Success = true,
ShipmentId = Guid.NewGuid().ToString(),
TrackingNumber = trackingNumber,
EstimatedDeliveryDate = DateTime.UtcNow.AddDays(GetMockDeliveryDays(request.Country)),
Cost = CalculateMockShippingCost(request.WeightInGrams, request.Country, request.ServiceCode ?? "1st Class"),
LabelUrl = $"https://mock-royal-mail/labels/{trackingNumber}.pdf"
};
}
private TrackingResult CreateMockTrackingResult(string trackingNumber)
{
var statuses = new[] { "Collected", "In Transit", "Out for Delivery", "Delivered" };
var randomStatus = statuses[new Random().Next(statuses.Length)];
return new TrackingResult
{
Success = true,
TrackingNumber = trackingNumber,
Status = randomStatus,
LastUpdate = DateTime.UtcNow.AddHours(-2),
EstimatedDelivery = DateTime.UtcNow.AddDays(1),
TrackingEvents = new List<TrackingEvent>
{
new() { Timestamp = DateTime.UtcNow.AddDays(-1), Location = "London", Description = "Item collected", Status = "Collected" },
new() { Timestamp = DateTime.UtcNow.AddHours(-6), Location = "Sorting Office", Description = "Item processed", Status = "In Transit" },
new() { Timestamp = DateTime.UtcNow.AddHours(-2), Location = "Local Depot", Description = "Item arriving at delivery office", Status = randomStatus }
}
};
}
private List<RoyalMailServiceOption> CreateMockServices(decimal weight, string country)
{
var services = new List<RoyalMailServiceOption>();
if (country.Equals("United Kingdom", StringComparison.OrdinalIgnoreCase))
{
services.Add(new RoyalMailServiceOption
{
Code = "1st Class",
Name = "First Class",
Description = "Next working day delivery",
Price = CalculateMockShippingCost(weight, country, "1st Class"),
EstimatedDeliveryDays = 1,
MaxWeight = 2000,
SupportsTracking = true
});
services.Add(new RoyalMailServiceOption
{
Code = "2nd Class",
Name = "Second Class",
Description = "2-3 working days delivery",
Price = CalculateMockShippingCost(weight, country, "2nd Class"),
EstimatedDeliveryDays = 3,
MaxWeight = 2000,
SupportsTracking = false
});
services.Add(new RoyalMailServiceOption
{
Code = "Signed For",
Name = "Signed For 1st Class",
Description = "Next working day with signature",
Price = CalculateMockShippingCost(weight, country, "Signed For"),
EstimatedDeliveryDays = 1,
MaxWeight = 2000,
SupportsTracking = true
});
}
else
{
services.Add(new RoyalMailServiceOption
{
Code = "International Standard",
Name = "International Standard",
Description = "5-7 working days to Europe",
Price = CalculateMockShippingCost(weight, country, "International Standard"),
EstimatedDeliveryDays = 7,
MaxWeight = 2000,
SupportsTracking = true
});
services.Add(new RoyalMailServiceOption
{
Code = "International Tracked",
Name = "International Tracked & Signed",
Description = "3-5 working days with tracking",
Price = CalculateMockShippingCost(weight, country, "International Tracked"),
EstimatedDeliveryDays = 5,
MaxWeight = 2000,
SupportsTracking = true
});
}
return services;
}
private decimal CalculateMockShippingCost(decimal weight, string country, string serviceCode)
{
var baseRate = country.Equals("United Kingdom", StringComparison.OrdinalIgnoreCase) ? 2.50m : 8.50m;
var weightRate = weight / 100 * 0.50m; // £0.50 per 100g
var serviceMultiplier = serviceCode switch
{
"1st Class" => 1.2m,
"2nd Class" => 1.0m,
"Signed For" => 1.5m,
"International Standard" => 2.0m,
"International Tracked" => 3.0m,
_ => 1.0m
};
return Math.Round(baseRate + weightRate * serviceMultiplier, 2);
}
private int GetMockDeliveryDays(string country)
{
return country.Equals("United Kingdom", StringComparison.OrdinalIgnoreCase) ? 2 : 7;
}
private string GenerateMockTrackingNumber()
{
var random = new Random();
return $"RM{random.Next(100000000, 999999999)}GB";
}
private byte[] GenerateMockLabel(string shipmentId)
{
// Generate a simple mock PDF label (in production this would be the actual Royal Mail label)
var mockPdfContent = $"%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>\nendobj\n4 0 obj\n<< /Length 44 >>\nstream\nBT\n/F1 12 Tf\n100 700 Td\n(Royal Mail Label - {shipmentId}) Tj\nET\nendstream\nendobj\nxref\n0 5\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \n0000000174 00000 n \ntrailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n267\n%%EOF";
return Encoding.UTF8.GetBytes(mockPdfContent);
}
}
// DTOs and response models
public class CreateShipmentRequest
{
public string RecipientName { get; set; } = string.Empty;
public string AddressLine1 { get; set; } = string.Empty;
public string? AddressLine2 { get; set; }
public string City { get; set; } = string.Empty;
public string PostCode { get; set; } = string.Empty;
public string Country { get; set; } = "United Kingdom";
public decimal WeightInGrams { get; set; }
public decimal? Length { get; set; }
public decimal? Width { get; set; }
public decimal? Height { get; set; }
public decimal Value { get; set; }
public string? ServiceCode { get; set; }
public string? Reference { get; set; }
public decimal? EstimatedCost { get; set; }
}
public class CreateShipmentResult
{
public bool Success { get; set; }
public string? ShipmentId { get; set; }
public string? TrackingNumber { get; set; }
public string? LabelUrl { get; set; }
public DateTime? EstimatedDeliveryDate { get; set; }
public decimal Cost { get; set; }
public string? ErrorMessage { get; set; }
}
public class TrackingResult
{
public bool Success { get; set; }
public string TrackingNumber { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTime LastUpdate { get; set; }
public DateTime? EstimatedDelivery { get; set; }
public List<TrackingEvent> TrackingEvents { get; set; } = new();
public string? ErrorMessage { get; set; }
}
public class TrackingEvent
{
public DateTime Timestamp { get; set; }
public string Location { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
}
public class RoyalMailServiceOption
{
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public int EstimatedDeliveryDays { get; set; }
public decimal MaxWeight { get; set; }
public bool SupportsTracking { get; set; }
}
// API Response models
public class RoyalMailTokenResponse
{
public string AccessToken { get; set; } = string.Empty;
public int ExpiresIn { get; set; }
public string TokenType { get; set; } = string.Empty;
}
public class RoyalMailShipmentResponse
{
public string ShipmentId { get; set; } = string.Empty;
public string TrackingNumber { get; set; } = string.Empty;
public string? LabelUrl { get; set; }
public DateTime EstimatedDelivery { get; set; }
}
public class RoyalMailTrackingResponse
{
public string Status { get; set; } = string.Empty;
public DateTime LastUpdate { get; set; }
public DateTime? EstimatedDelivery { get; set; }
public List<RoyalMailTrackingEvent> Events { get; set; } = new();
}
public class RoyalMailTrackingEvent
{
public DateTime Timestamp { get; set; }
public string Location { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
}
public class RoyalMailShippingServiceResponse
{
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Price { get; set; }
public int EstimatedDeliveryDays { get; set; }
public decimal MaxWeight { get; set; }
public bool SupportsTracking { get; set; }
}