diff --git a/EbayListingTool/Services/EbayListingService.cs b/EbayListingTool/Services/EbayListingService.cs index fdd0f3d..746392e 100644 --- a/EbayListingTool/Services/EbayListingService.cs +++ b/EbayListingTool/Services/EbayListingService.cs @@ -1,4 +1,4 @@ -using System.Net.Http; +using System.Net.Http; using System.Net.Http.Headers; using System.Text; using EbayListingTool.Models; @@ -16,6 +16,13 @@ public class EbayListingService private static readonly HttpClient _http = new(); // REST / Inventory / Account APIs private static readonly HttpClient _photoHttp = new(); // Trading API (photo upload) + private static readonly JsonSerializerSettings _jsonSettings = new() + { NullValueHandling = NullValueHandling.Ignore }; + + private static readonly string _logPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "EbayListingTool", "crash_log.txt"); + // Per-session cache of eBay account IDs — fetched once, reused for every listing private string? _fulfillmentPolicyId; private string? _paymentPolicyId; @@ -43,13 +50,10 @@ public class EbayListingService { var token = await _auth.GetValidAccessTokenAsync(); - // 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 if (string.IsNullOrEmpty(draft.CategoryId) && !string.IsNullOrEmpty(draft.CategoryName)) { draft.CategoryId = await _categoryService.GetCategoryIdByKeywordAsync(draft.CategoryName) @@ -59,14 +63,9 @@ public class EbayListingService if (string.IsNullOrEmpty(draft.CategoryId)) throw new InvalidOperationException("Please select a category before posting."); - // 3. Create inventory item await CreateInventoryItemAsync(draft, imageUrls, token); - - // 4. Create offer var offerId = await CreateOfferAsync(draft, token); - - // 5. Publish offer → get item ID - var itemId = await PublishOfferAsync(offerId, token); + var itemId = await PublishOfferAsync(offerId, token); draft.EbayItemId = itemId; var domain = _auth.BaseUrl.Contains("sandbox") ? "sandbox.ebay.co.uk" : "ebay.co.uk"; @@ -77,11 +76,6 @@ public class EbayListingService // ---- 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; @@ -145,7 +139,7 @@ public class EbayListingService 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."); + "Please set one up in My eBay \u2192 Account \u2192 Business policies, then try again."); } if (_returnPolicyId == null) @@ -167,7 +161,7 @@ public class EbayListingService 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."); + "Please set one up in My eBay \u2192 Account \u2192 Business policies, then try again."); } if (_merchantLocationKey == null) @@ -179,13 +173,12 @@ public class EbayListingService if (res.IsSuccessStatusCode) { - var arr = JObject.Parse(json)["locations"] as JArray; + 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); @@ -194,73 +187,28 @@ public class EbayListingService } } - /// Parses an eBay error JSON body into a user-friendly message. - private static string ExtractEbayError(string json, string policyType) - { - try - { - var errors = JObject.Parse(json)["errors"] as JArray; - var first = errors?.FirstOrDefault() as JObject; - if (first != null) - { - var errorId = first["errorId"]?.Value() ?? 0; - var longMsg = first["longMessage"]?.ToString() ?? first["message"]?.ToString() ?? ""; - - // 20403 = account not opted in to Business Policies - if (errorId == 20403 || longMsg.Contains("not eligible for Business Policy")) - return $"Your eBay account is not set up for Business Policies, which are required to post listings via the API.\n\n" + - $"To fix this:\n" + - $"1. Log in to the eBay Seller Hub (or sandbox Seller Hub)\n" + - $"2. Go to Account \u2192 Business policies\n" + - $"3. Create at least one Postage, Payment and Returns policy\n\n" + - $"Once done, click Post to eBay again."; - - if (!string.IsNullOrWhiteSpace(longMsg)) - return $"eBay {policyType} error: {longMsg}"; - } - } - catch { } - return $"Could not fetch {policyType} from eBay. Please check your account settings."; - } - - /// - /// Called automatically on first 20403 error. Opts the account in to SELLING_POLICY_MANAGEMENT - /// then creates minimal default policies so posting can proceed without manual eBay setup. - /// private static void LogSetup(string msg) { - var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "EbayListingTool"); - Directory.CreateDirectory(dir); - File.AppendAllText(Path.Combine(dir, "crash_log.txt"), - $"{DateTime.Now:HH:mm:ss} [Setup] {msg}{Environment.NewLine}"); + Directory.CreateDirectory(Path.GetDirectoryName(_logPath)!); + File.AppendAllText(_logPath, $"{DateTime.Now:HH:mm:ss} [Setup] {msg}{Environment.NewLine}"); } private async Task SetupDefaultBusinessPoliciesAsync(string token) { - // Step 1: opt in to the Business Policies programme try { var optInBody = JsonConvert.SerializeObject(new { programType = "SELLING_POLICY_MANAGEMENT" }); using var req = MakeRequest(HttpMethod.Post, $"{_auth.BaseUrl}/sell/account/v1/program/opt_in", token); req.Content = new StringContent(optInBody, Encoding.UTF8, "application/json"); - var optInRes = await _http.SendAsync(req); - var optInBody2 = await optInRes.Content.ReadAsStringAsync(); - LogSetup($"opt_in → {(int)optInRes.StatusCode} {optInBody2}"); + var res = await _http.SendAsync(req); + var body = await res.Content.ReadAsStringAsync(); + LogSetup($"opt_in \u2192 {(int)res.StatusCode} {body}"); } catch (Exception ex) { LogSetup($"opt_in exception: {ex.Message}"); } - // Step 2: create one policy of each required type (ignore errors — they may already exist) - await TryCreateFulfillmentPolicyAsync(token); - await TryCreatePaymentPolicyAsync(token); - await TryCreateReturnPolicyAsync(token); - } - - private async Task TryCreateFulfillmentPolicyAsync(string token) - { - try - { - var body = new + await Task.WhenAll( + TryCreatePolicyAsync(token, "fulfillment_policy", new { name = "Standard UK Shipping", marketplaceId = "EBAY_GB", @@ -278,52 +226,22 @@ public class EbayListingService { sortOrder = 1, shippingCarrierCode = "ROYALMAIL", - shippingServiceCode = "UK_OtherCourier", + shippingServiceCode = "UK_OtherCourier", shippingCost = new { value = "2.85", currency = "GBP" }, additionalShippingCost = new { value = "0.00", currency = "GBP" } } } } } - }; - using var req = MakeRequest(HttpMethod.Post, - $"{_auth.BaseUrl}/sell/account/v1/fulfillment_policy", token); - req.Content = new StringContent( - JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json"); - var res2 = await _http.SendAsync(req); - var body2 = await res2.Content.ReadAsStringAsync(); - LogSetup($"fulfillment_policy create → {(int)res2.StatusCode} {body2}"); - } - catch (Exception ex) { LogSetup($"fulfillment_policy exception: {ex.Message}"); } - } - - private async Task TryCreatePaymentPolicyAsync(string token) - { - try - { - var body = new + }), + TryCreatePolicyAsync(token, "payment_policy", new { - name = "Managed Payments", - marketplaceId = "EBAY_GB", - categoryTypes = new[] { new { name = "ALL_EXCLUDING_MOTORS_VEHICLES" } }, + name = "Managed Payments", + marketplaceId = "EBAY_GB", + categoryTypes = new[] { new { name = "ALL_EXCLUDING_MOTORS_VEHICLES" } }, paymentMethods = Array.Empty() - }; - using var req = MakeRequest(HttpMethod.Post, - $"{_auth.BaseUrl}/sell/account/v1/payment_policy", token); - req.Content = new StringContent( - JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json"); - var res2 = await _http.SendAsync(req); - var body2 = await res2.Content.ReadAsStringAsync(); - LogSetup($"payment_policy create → {(int)res2.StatusCode} {body2}"); - } - catch (Exception ex) { LogSetup($"payment_policy exception: {ex.Message}"); } - } - - private async Task TryCreateReturnPolicyAsync(string token) - { - try - { - var body = new + }), + TryCreatePolicyAsync(token, "return_policy", new { name = "Standard Returns", marketplaceId = "EBAY_GB", @@ -332,22 +250,29 @@ public class EbayListingService returnPeriod = new { value = 30, unit = "DAY" }, refundMethod = "MONEY_BACK", returnShippingCostPayer = "BUYER" - }; + }) + ); + } + + private async Task TryCreatePolicyAsync(string token, string policyType, object body) + { + try + { using var req = MakeRequest(HttpMethod.Post, - $"{_auth.BaseUrl}/sell/account/v1/return_policy", token); + $"{_auth.BaseUrl}/sell/account/v1/{policyType}", token); req.Content = new StringContent( JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json"); - var res2 = await _http.SendAsync(req); - var body2 = await res2.Content.ReadAsStringAsync(); - LogSetup($"return_policy create → {(int)res2.StatusCode} {body2}"); + var res = await _http.SendAsync(req); + var respBody = await res.Content.ReadAsStringAsync(); + LogSetup($"{policyType} create \u2192 {(int)res.StatusCode} {respBody}"); } - catch (Exception ex) { LogSetup($"return_policy exception: {ex.Message}"); } + catch (Exception ex) { LogSetup($"{policyType} exception: {ex.Message}"); } } private async Task CreateMerchantLocationAsync(string token, string postcode) { if (string.IsNullOrWhiteSpace(postcode)) - postcode = "N/A"; // eBay allows this when postcode is genuinely unknown + postcode = "N/A"; var body = new { @@ -355,8 +280,8 @@ public class EbayListingService { address = new { postalCode = postcode, country = "GB" } }, - locationTypes = new[] { "WAREHOUSE" }, - name = "Home", + locationTypes = new[] { "WAREHOUSE" }, + name = "Home", merchantLocationStatus = "ENABLED" }; @@ -383,10 +308,8 @@ public class EbayListingService { shipToLocationAvailability = new { quantity = draft.Quantity } }, - condition = draft.ConditionId, - conditionDescription = draft.Condition == ItemCondition.Used ? "Used — see photos" : null, - description = draft.Description, - title = draft.Title, + condition = draft.ConditionId, + conditionDescription = draft.Condition == ItemCondition.Used ? "Used \u2014 see photos" : null, product = new { title = draft.Title, @@ -396,14 +319,9 @@ public class EbayListingService } }; - var json = JsonConvert.SerializeObject(inventoryItem, new JsonSerializerSettings - { - NullValueHandling = NullValueHandling.Ignore - }); - var url = $"{_auth.BaseUrl}/sell/inventory/v1/inventory_item/{Uri.EscapeDataString(draft.Sku)}"; using var req = MakeRequest(HttpMethod.Put, url, token); - req.Content = new StringContent(json, Encoding.UTF8, "application/json"); + req.Content = new StringContent(JsonConvert.SerializeObject(inventoryItem, _jsonSettings), Encoding.UTF8, "application/json"); req.Content.Headers.Add("Content-Language", "en-GB"); var res = await _http.SendAsync(req); @@ -432,24 +350,16 @@ public class EbayListingService paymentPolicyId = _paymentPolicyId, returnPolicyId = _returnPolicyId }, - pricingSummary = new - { - price = new { value = draft.Price.ToString("F2"), currency = "GBP" } - }, + pricingSummary = new { price = new { value = draft.Price.ToString("F2"), currency = "GBP" } }, merchantLocationKey = _merchantLocationKey, - tax = new { vatPercentage = 0, applyTax = false } + tax = new { vatPercentage = 0, applyTax = false } }; - var json = JsonConvert.SerializeObject(offer, new JsonSerializerSettings - { - NullValueHandling = NullValueHandling.Ignore - }); - using var req = MakeRequest(HttpMethod.Post, $"{_auth.BaseUrl}/sell/inventory/v1/offer", token); - req.Content = new StringContent(json, Encoding.UTF8, "application/json"); + req.Content = new StringContent(JsonConvert.SerializeObject(offer, _jsonSettings), Encoding.UTF8, "application/json"); - var res = await _http.SendAsync(req); + var res = await _http.SendAsync(req); var responseJson = await res.Content.ReadAsStringAsync(); if (!res.IsSuccessStatusCode) @@ -481,29 +391,22 @@ public class EbayListingService private async Task> UploadPhotosAsync(List photoPaths, string token) { - var urls = new List(); - if (photoPaths.Count == 0) return urls; + if (photoPaths.Count == 0) return []; var tradingBase = _auth.BaseUrl.Contains("sandbox") ? "https://api.sandbox.ebay.com/ws/api.dll" : "https://api.ebay.com/ws/api.dll"; - foreach (var path in photoPaths.Take(12)) + var semaphore = new SemaphoreSlim(4); + var tasks = photoPaths.Take(12).Select(async path => { - if (!File.Exists(path)) continue; - try - { - var url = await UploadSinglePhotoAsync(path, tradingBase, token); - if (!string.IsNullOrEmpty(url)) - urls.Add(url); - } - catch - { - // Skip failed photos; don't abort the whole listing - } - } + await semaphore.WaitAsync(); + try { return await UploadSinglePhotoAsync(path, tradingBase, token); } + catch { return null; } + finally { semaphore.Release(); } + }); - return urls; + return [.. (await Task.WhenAll(tasks)).Where(u => !string.IsNullOrEmpty(u))]; } private async Task UploadSinglePhotoAsync(string filePath, string tradingUrl, string token) @@ -522,7 +425,6 @@ public class EbayListingService """; - // 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); @@ -546,7 +448,6 @@ public class EbayListingService // ---- Helpers ---- - /// Creates a pre-authorised request targeting the eBay REST APIs. private HttpRequestMessage MakeRequest(HttpMethod method, string url, string token) { var req = new HttpRequestMessage(method, url); @@ -555,8 +456,3 @@ public class EbayListingService return req; } } - - - - -