diff --git a/EbayListingTool/Services/EbayListingService.cs b/EbayListingTool/Services/EbayListingService.cs index 30bf643..a8403aa 100644 --- a/EbayListingTool/Services/EbayListingService.cs +++ b/EbayListingTool/Services/EbayListingService.cs @@ -12,17 +12,39 @@ public class EbayListingService private readonly EbayAuthService _auth; private readonly EbayCategoryService _categoryService; + // Shared clients — avoids socket exhaustion from per-call `new HttpClient()` + private static readonly HttpClient _http = new(); // REST / Inventory / Account APIs + private static readonly HttpClient _photoHttp = new(); // Trading API (photo upload) + + // Per-session cache of eBay account IDs — fetched once, reused for every listing + private string? _fulfillmentPolicyId; + private string? _paymentPolicyId; + private string? _returnPolicyId; + private string? _merchantLocationKey; + public EbayListingService(EbayAuthService auth, EbayCategoryService categoryService) { _auth = auth; _categoryService = categoryService; } + /// Call when the user disconnects so stale IDs are not reused after re-login. + public void ClearCache() + { + _fulfillmentPolicyId = null; + _paymentPolicyId = null; + _returnPolicyId = null; + _merchantLocationKey = null; + } + public async Task PostListingAsync(ListingDraft draft) { var token = await _auth.GetValidAccessTokenAsync(); - // 1. Upload photos and get URLs + // Resolve business policies and merchant location before touching inventory/offers + await EnsurePoliciesAndLocationAsync(token, draft.Postcode); + + // 1. Upload photos and get eBay-hosted URLs var imageUrls = await UploadPhotosAsync(draft.PhotoPaths, token); // 2. Resolve category if not set @@ -51,31 +73,156 @@ public class EbayListingService return draft.EbayListingUrl; } + // ---- Setup: policies + location ---- + + /// + /// Fetches fulfillment, payment and return policy IDs from the seller's eBay account, + /// and ensures at least one merchant location exists (creating "home" from the seller's + /// postcode if needed). Results are cached for the session. + /// + private async Task EnsurePoliciesAndLocationAsync(string token, string postcode) + { + var baseUrl = _auth.BaseUrl; + + if (_fulfillmentPolicyId == null) + { + using var req = MakeRequest(HttpMethod.Get, + $"{baseUrl}/sell/account/v1/fulfillment_policy?marketplace_id=EBAY_GB", token); + var res = await _http.SendAsync(req); + var json = await res.Content.ReadAsStringAsync(); + + if (!res.IsSuccessStatusCode) + throw new HttpRequestException( + $"Could not fetch fulfillment policies ({(int)res.StatusCode}): {json}"); + + var arr = JObject.Parse(json)["fulfillmentPolicies"] as JArray; + _fulfillmentPolicyId = arr?.Count > 0 + ? arr[0]["fulfillmentPolicyId"]?.ToString() + : null; + + if (_fulfillmentPolicyId == null) + throw new InvalidOperationException( + "No fulfillment policy found on your eBay account.\n\n" + + "Please set one up in My eBay → Account → Business policies, then try again."); + } + + if (_paymentPolicyId == null) + { + using var req = MakeRequest(HttpMethod.Get, + $"{baseUrl}/sell/account/v1/payment_policy?marketplace_id=EBAY_GB", token); + var res = await _http.SendAsync(req); + var json = await res.Content.ReadAsStringAsync(); + + if (!res.IsSuccessStatusCode) + throw new HttpRequestException( + $"Could not fetch payment policies ({(int)res.StatusCode}): {json}"); + + var arr = JObject.Parse(json)["paymentPolicies"] as JArray; + _paymentPolicyId = arr?.Count > 0 + ? arr[0]["paymentPolicyId"]?.ToString() + : null; + + if (_paymentPolicyId == null) + throw new InvalidOperationException( + "No payment policy found on your eBay account.\n\n" + + "Please set one up in My eBay → Account → Business policies, then try again."); + } + + if (_returnPolicyId == null) + { + using var req = MakeRequest(HttpMethod.Get, + $"{baseUrl}/sell/account/v1/return_policy?marketplace_id=EBAY_GB", token); + var res = await _http.SendAsync(req); + var json = await res.Content.ReadAsStringAsync(); + + if (!res.IsSuccessStatusCode) + throw new HttpRequestException( + $"Could not fetch return policies ({(int)res.StatusCode}): {json}"); + + var arr = JObject.Parse(json)["returnPolicies"] as JArray; + _returnPolicyId = arr?.Count > 0 + ? arr[0]["returnPolicyId"]?.ToString() + : null; + + if (_returnPolicyId == null) + throw new InvalidOperationException( + "No return policy found on your eBay account.\n\n" + + "Please set one up in My eBay → Account → Business policies, then try again."); + } + + if (_merchantLocationKey == null) + { + using var req = MakeRequest(HttpMethod.Get, + $"{baseUrl}/sell/inventory/v1/location", token); + var res = await _http.SendAsync(req); + var json = await res.Content.ReadAsStringAsync(); + + if (res.IsSuccessStatusCode) + { + var arr = JObject.Parse(json)["locations"] as JArray; + _merchantLocationKey = arr?.Count > 0 + ? arr[0]["merchantLocationKey"]?.ToString() + : null; + } + + // No existing locations — create one from the seller's postcode + if (_merchantLocationKey == null) + { + await CreateMerchantLocationAsync(token, postcode); + _merchantLocationKey = "home"; + } + } + } + + private async Task CreateMerchantLocationAsync(string token, string postcode) + { + if (string.IsNullOrWhiteSpace(postcode)) + postcode = "N/A"; // eBay allows this when postcode is genuinely unknown + + var body = new + { + location = new + { + address = new { postalCode = postcode, country = "GB" } + }, + locationTypes = new[] { "WAREHOUSE" }, + name = "Home", + merchantLocationStatus = "ENABLED" + }; + + using var req = MakeRequest(HttpMethod.Post, + $"{_auth.BaseUrl}/sell/inventory/v1/location/home", token); + req.Content = new StringContent( + JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json"); + + var res = await _http.SendAsync(req); + var json = await res.Content.ReadAsStringAsync(); + + if (!res.IsSuccessStatusCode) + throw new HttpRequestException( + $"Could not create merchant location ({(int)res.StatusCode}): {json}"); + } + + // ---- Inventory item ---- + private async Task CreateInventoryItemAsync(ListingDraft draft, List imageUrls, string token) { - using var http = BuildClient(token); - - var aspects = new Dictionary>(); - var inventoryItem = new { availability = new { - shipToLocationAvailability = new - { - quantity = draft.Quantity - } + shipToLocationAvailability = new { quantity = draft.Quantity } }, condition = draft.ConditionId, - conditionDescription = draft.Condition == ItemCondition.Used ? "Used - see photos" : null, + conditionDescription = draft.Condition == ItemCondition.Used ? "Used — see photos" : null, description = draft.Description, title = draft.Title, product = new { - title = draft.Title, + title = draft.Title, description = draft.Description, - imageUrls = imageUrls.Count > 0 ? imageUrls : null, - aspects = aspects.Count > 0 ? aspects : null + imageUrls = imageUrls.Count > 0 ? imageUrls : null, + aspects = (object?)null } }; @@ -85,40 +232,41 @@ public class EbayListingService }); var url = $"{_auth.BaseUrl}/sell/inventory/v1/inventory_item/{Uri.EscapeDataString(draft.Sku)}"; - var request = new HttpRequestMessage(HttpMethod.Put, url) - { - Content = new StringContent(json, Encoding.UTF8, "application/json") - }; - request.Content.Headers.Add("Content-Language", "en-GB"); + using var req = MakeRequest(HttpMethod.Put, url, token); + req.Content = new StringContent(json, Encoding.UTF8, "application/json"); + req.Content.Headers.Add("Content-Language", "en-GB"); - var response = await http.SendAsync(request); - if (!response.IsSuccessStatusCode) + var res = await _http.SendAsync(req); + if (!res.IsSuccessStatusCode) { - var error = await response.Content.ReadAsStringAsync(); - throw new HttpRequestException($"Failed to create inventory item: {error}"); + var err = await res.Content.ReadAsStringAsync(); + throw new HttpRequestException($"Failed to create inventory item: {err}"); } } + // ---- Offer ---- + private async Task CreateOfferAsync(ListingDraft draft, string token) { - using var http = BuildClient(token); - - var listingPolicies = BuildListingPolicies(draft); - var offer = new { - sku = draft.Sku, - marketplaceId = "EBAY_GB", - format = draft.Format == ListingFormat.Auction ? "AUCTION" : "FIXED_PRICE", - availableQuantity = draft.Quantity, - categoryId = draft.CategoryId, + sku = draft.Sku, + marketplaceId = "EBAY_GB", + format = draft.Format == ListingFormat.Auction ? "AUCTION" : "FIXED_PRICE", + availableQuantity = draft.Quantity, + categoryId = draft.CategoryId, listingDescription = draft.Description, - listingPolicies, + listingPolicies = new + { + fulfillmentPolicyId = _fulfillmentPolicyId, + paymentPolicyId = _paymentPolicyId, + returnPolicyId = _returnPolicyId + }, pricingSummary = new { price = new { value = draft.Price.ToString("F2"), currency = "GBP" } }, - merchantLocationKey = "home", + merchantLocationKey = _merchantLocationKey, tax = new { vatPercentage = 0, applyTax = false } }; @@ -127,39 +275,45 @@ public class EbayListingService NullValueHandling = NullValueHandling.Ignore }); - var url = $"{_auth.BaseUrl}/sell/inventory/v1/offer"; - var response = await http.PostAsync(url, new StringContent(json, Encoding.UTF8, "application/json")); - var responseJson = await response.Content.ReadAsStringAsync(); + using var req = MakeRequest(HttpMethod.Post, + $"{_auth.BaseUrl}/sell/inventory/v1/offer", token); + req.Content = new StringContent(json, Encoding.UTF8, "application/json"); - if (!response.IsSuccessStatusCode) + var res = await _http.SendAsync(req); + var responseJson = await res.Content.ReadAsStringAsync(); + + if (!res.IsSuccessStatusCode) throw new HttpRequestException($"Failed to create offer: {responseJson}"); - var obj = JObject.Parse(responseJson); - return obj["offerId"]?.ToString() + return JObject.Parse(responseJson)["offerId"]?.ToString() ?? throw new InvalidOperationException("No offerId in create offer response."); } + // ---- Publish ---- + private async Task PublishOfferAsync(string offerId, string token) { - using var http = BuildClient(token); - var url = $"{_auth.BaseUrl}/sell/inventory/v1/offer/{offerId}/publish"; - var response = await http.PostAsync(url, new StringContent("{}", Encoding.UTF8, "application/json")); - var responseJson = await response.Content.ReadAsStringAsync(); + using var req = MakeRequest(HttpMethod.Post, + $"{_auth.BaseUrl}/sell/inventory/v1/offer/{offerId}/publish", token); + req.Content = new StringContent("{}", Encoding.UTF8, "application/json"); - if (!response.IsSuccessStatusCode) + var res = await _http.SendAsync(req); + var responseJson = await res.Content.ReadAsStringAsync(); + + if (!res.IsSuccessStatusCode) throw new HttpRequestException($"Failed to publish offer: {responseJson}"); - var obj = JObject.Parse(responseJson); - return obj["listingId"]?.ToString() + return JObject.Parse(responseJson)["listingId"]?.ToString() ?? throw new InvalidOperationException("No listingId in publish response."); } + // ---- Photo upload ---- + private async Task> UploadPhotosAsync(List photoPaths, string token) { var urls = new List(); if (photoPaths.Count == 0) return urls; - // Use Trading API UploadSiteHostedPictures for each photo var tradingBase = _auth.BaseUrl.Contains("sandbox") ? "https://api.sandbox.ebay.com/ws/api.dll" : "https://api.ebay.com/ws/api.dll"; @@ -167,7 +321,6 @@ public class EbayListingService foreach (var path in photoPaths.Take(12)) { if (!File.Exists(path)) continue; - try { var url = await UploadSinglePhotoAsync(path, tradingBase, token); @@ -176,7 +329,7 @@ public class EbayListingService } catch { - // Skip failed photo uploads, don't abort the whole listing + // Skip failed photos; don't abort the whole listing } } @@ -186,8 +339,7 @@ public class EbayListingService private async Task UploadSinglePhotoAsync(string filePath, string tradingUrl, string token) { var fileBytes = await File.ReadAllBytesAsync(filePath); - var base64 = Convert.ToBase64String(fileBytes); - var ext = Path.GetExtension(filePath).TrimStart('.').ToUpper(); + var ext = Path.GetExtension(filePath).TrimStart('.').ToLower(); var soapBody = $""" @@ -197,78 +349,39 @@ public class EbayListingService {Path.GetFileNameWithoutExtension(filePath)} Supersize - https://example.com/placeholder.jpg """; - // For binary upload, use multipart - using var http = new HttpClient(); - http.DefaultRequestHeaders.Add("X-EBAY-API-SITEID", "3"); - http.DefaultRequestHeaders.Add("X-EBAY-API-COMPATIBILITY-LEVEL", "967"); - http.DefaultRequestHeaders.Add("X-EBAY-API-CALL-NAME", "UploadSiteHostedPictures"); - http.DefaultRequestHeaders.Add("X-EBAY-API-IAF-TOKEN", token); - + // Use HttpRequestMessage with _photoHttp so we don't create a new socket per photo using var content = new MultipartFormDataContent(); content.Add(new StringContent(soapBody, Encoding.UTF8, "text/xml"), "XML Payload"); var imageContent = new ByteArrayContent(fileBytes); - imageContent.Headers.ContentType = new MediaTypeHeaderValue($"image/{ext.ToLower()}"); - content.Add(imageContent, "dummy", Path.GetFileName(filePath)); + imageContent.Headers.ContentType = new MediaTypeHeaderValue($"image/{ext}"); + content.Add(imageContent, "image", Path.GetFileName(filePath)); - var response = await http.PostAsync(tradingUrl, content); + using var req = new HttpRequestMessage(HttpMethod.Post, tradingUrl); + req.Headers.Add("X-EBAY-API-SITEID", "3"); // UK site + req.Headers.Add("X-EBAY-API-COMPATIBILITY-LEVEL", "967"); + req.Headers.Add("X-EBAY-API-CALL-NAME", "UploadSiteHostedPictures"); + req.Headers.Add("X-EBAY-API-IAF-TOKEN", token); + req.Content = content; + + var response = await _photoHttp.SendAsync(req); var responseXml = await response.Content.ReadAsStringAsync(); - // Parse URL from XML response var match = System.Text.RegularExpressions.Regex.Match( responseXml, @"(.*?)"); return match.Success ? match.Groups[1].Value : null; } - private JObject BuildListingPolicies(ListingDraft draft) - { - var (serviceCode, costValue) = draft.Postage switch - { - PostageOption.RoyalMailFirstClass => ("UK_RoyalMailFirstClass", "1.50"), - PostageOption.RoyalMailSecondClass => ("UK_RoyalMailSecondClass", "1.20"), - PostageOption.RoyalMailTracked24 => ("UK_RoyalMailTracked24", "2.95"), - PostageOption.RoyalMailTracked48 => ("UK_RoyalMailTracked48", "2.50"), - PostageOption.FreePostage => ("UK_RoyalMailSecondClass", "0.00"), - _ => ("UK_CollectionInPerson", "0.00") - }; + // ---- Helpers ---- - return new JObject - { - ["shippingPolicyName"] = "Default", - ["paymentPolicyName"] = "Default", - ["returnPolicyName"] = "Default", - ["shippingCostType"] = "FLAT_RATE", - ["shippingOptions"] = new JArray - { - new JObject - { - ["optionType"] = "DOMESTIC", - ["costType"] = "FLAT_RATE", - ["shippingServices"] = new JArray - { - new JObject - { - ["shippingServiceCode"] = serviceCode, - ["shippingCost"] = new JObject - { - ["value"] = costValue, - ["currency"] = "GBP" - } - } - } - } - } - }; - } - - private HttpClient BuildClient(string token) + /// Creates a pre-authorised request targeting the eBay REST APIs. + private HttpRequestMessage MakeRequest(HttpMethod method, string url, string token) { - var http = new HttpClient(); - http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); - http.DefaultRequestHeaders.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB"); - return http; + var req = new HttpRequestMessage(method, url); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + req.Headers.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB"); + return req; } } diff --git a/EbayListingTool/Views/MainWindow.xaml.cs b/EbayListingTool/Views/MainWindow.xaml.cs index 6e4bde5..131bd7a 100644 --- a/EbayListingTool/Views/MainWindow.xaml.cs +++ b/EbayListingTool/Views/MainWindow.xaml.cs @@ -69,6 +69,7 @@ public partial class MainWindow : MetroWindow private void DisconnectBtn_Click(object sender, RoutedEventArgs e) { _auth.Disconnect(); + _listingService.ClearCache(); // clear cached policy/location IDs for next login UpdateConnectionState(); SetStatus("Disconnected from eBay."); }