refactor: unify TryCreate* into one helper, parallel photo upload, static JsonSettings, remove dead ExtractEbayError

This commit is contained in:
2026-04-17 02:59:02 +01:00
parent c51342f46e
commit cdde3ae195

View File

@@ -1,4 +1,4 @@
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Text; using System.Text;
using EbayListingTool.Models; using EbayListingTool.Models;
@@ -16,6 +16,13 @@ public class EbayListingService
private static readonly HttpClient _http = new(); // REST / Inventory / Account APIs private static readonly HttpClient _http = new(); // REST / Inventory / Account APIs
private static readonly HttpClient _photoHttp = new(); // Trading API (photo upload) 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 // Per-session cache of eBay account IDs — fetched once, reused for every listing
private string? _fulfillmentPolicyId; private string? _fulfillmentPolicyId;
private string? _paymentPolicyId; private string? _paymentPolicyId;
@@ -43,13 +50,10 @@ public class EbayListingService
{ {
var token = await _auth.GetValidAccessTokenAsync(); var token = await _auth.GetValidAccessTokenAsync();
// Resolve business policies and merchant location before touching inventory/offers
await EnsurePoliciesAndLocationAsync(token, draft.Postcode); await EnsurePoliciesAndLocationAsync(token, draft.Postcode);
// 1. Upload photos and get eBay-hosted URLs
var imageUrls = await UploadPhotosAsync(draft.PhotoPaths, token); var imageUrls = await UploadPhotosAsync(draft.PhotoPaths, token);
// 2. Resolve category if not set
if (string.IsNullOrEmpty(draft.CategoryId) && !string.IsNullOrEmpty(draft.CategoryName)) if (string.IsNullOrEmpty(draft.CategoryId) && !string.IsNullOrEmpty(draft.CategoryName))
{ {
draft.CategoryId = await _categoryService.GetCategoryIdByKeywordAsync(draft.CategoryName) draft.CategoryId = await _categoryService.GetCategoryIdByKeywordAsync(draft.CategoryName)
@@ -59,14 +63,9 @@ public class EbayListingService
if (string.IsNullOrEmpty(draft.CategoryId)) if (string.IsNullOrEmpty(draft.CategoryId))
throw new InvalidOperationException("Please select a category before posting."); throw new InvalidOperationException("Please select a category before posting.");
// 3. Create inventory item
await CreateInventoryItemAsync(draft, imageUrls, token); await CreateInventoryItemAsync(draft, imageUrls, token);
// 4. Create offer
var offerId = await CreateOfferAsync(draft, token); var offerId = await CreateOfferAsync(draft, token);
var itemId = await PublishOfferAsync(offerId, token);
// 5. Publish offer → get item ID
var itemId = await PublishOfferAsync(offerId, token);
draft.EbayItemId = itemId; draft.EbayItemId = itemId;
var domain = _auth.BaseUrl.Contains("sandbox") ? "sandbox.ebay.co.uk" : "ebay.co.uk"; var domain = _auth.BaseUrl.Contains("sandbox") ? "sandbox.ebay.co.uk" : "ebay.co.uk";
@@ -77,11 +76,6 @@ public class EbayListingService
// ---- Setup: policies + location ---- // ---- Setup: policies + location ----
/// <summary>
/// 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.
/// </summary>
private async Task EnsurePoliciesAndLocationAsync(string token, string postcode) private async Task EnsurePoliciesAndLocationAsync(string token, string postcode)
{ {
var baseUrl = _auth.BaseUrl; var baseUrl = _auth.BaseUrl;
@@ -145,7 +139,7 @@ public class EbayListingService
if (_paymentPolicyId == null) if (_paymentPolicyId == null)
throw new InvalidOperationException( throw new InvalidOperationException(
"No payment policy found on your eBay account.\n\n" + "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) if (_returnPolicyId == null)
@@ -167,7 +161,7 @@ public class EbayListingService
if (_returnPolicyId == null) if (_returnPolicyId == null)
throw new InvalidOperationException( throw new InvalidOperationException(
"No return policy found on your eBay account.\n\n" + "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) if (_merchantLocationKey == null)
@@ -179,13 +173,12 @@ public class EbayListingService
if (res.IsSuccessStatusCode) if (res.IsSuccessStatusCode)
{ {
var arr = JObject.Parse(json)["locations"] as JArray; var arr = JObject.Parse(json)["locations"] as JArray;
_merchantLocationKey = arr?.Count > 0 _merchantLocationKey = arr?.Count > 0
? arr[0]["merchantLocationKey"]?.ToString() ? arr[0]["merchantLocationKey"]?.ToString()
: null; : null;
} }
// No existing locations — create one from the seller's postcode
if (_merchantLocationKey == null) if (_merchantLocationKey == null)
{ {
await CreateMerchantLocationAsync(token, postcode); await CreateMerchantLocationAsync(token, postcode);
@@ -194,73 +187,28 @@ public class EbayListingService
} }
} }
/// <summary>Parses an eBay error JSON body into a user-friendly message.</summary>
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<int>() ?? 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.";
}
/// <summary>
/// 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.
/// </summary>
private static void LogSetup(string msg) private static void LogSetup(string msg)
{ {
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "EbayListingTool"); Directory.CreateDirectory(Path.GetDirectoryName(_logPath)!);
Directory.CreateDirectory(dir); File.AppendAllText(_logPath, $"{DateTime.Now:HH:mm:ss} [Setup] {msg}{Environment.NewLine}");
File.AppendAllText(Path.Combine(dir, "crash_log.txt"),
$"{DateTime.Now:HH:mm:ss} [Setup] {msg}{Environment.NewLine}");
} }
private async Task SetupDefaultBusinessPoliciesAsync(string token) private async Task SetupDefaultBusinessPoliciesAsync(string token)
{ {
// Step 1: opt in to the Business Policies programme
try try
{ {
var optInBody = JsonConvert.SerializeObject(new { programType = "SELLING_POLICY_MANAGEMENT" }); var optInBody = JsonConvert.SerializeObject(new { programType = "SELLING_POLICY_MANAGEMENT" });
using var req = MakeRequest(HttpMethod.Post, using var req = MakeRequest(HttpMethod.Post,
$"{_auth.BaseUrl}/sell/account/v1/program/opt_in", token); $"{_auth.BaseUrl}/sell/account/v1/program/opt_in", token);
req.Content = new StringContent(optInBody, Encoding.UTF8, "application/json"); req.Content = new StringContent(optInBody, Encoding.UTF8, "application/json");
var optInRes = await _http.SendAsync(req); var res = await _http.SendAsync(req);
var optInBody2 = await optInRes.Content.ReadAsStringAsync(); var body = await res.Content.ReadAsStringAsync();
LogSetup($"opt_in → {(int)optInRes.StatusCode} {optInBody2}"); LogSetup($"opt_in \u2192 {(int)res.StatusCode} {body}");
} }
catch (Exception ex) { LogSetup($"opt_in exception: {ex.Message}"); } 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 Task.WhenAll(
await TryCreateFulfillmentPolicyAsync(token); TryCreatePolicyAsync(token, "fulfillment_policy", new
await TryCreatePaymentPolicyAsync(token);
await TryCreateReturnPolicyAsync(token);
}
private async Task TryCreateFulfillmentPolicyAsync(string token)
{
try
{
var body = new
{ {
name = "Standard UK Shipping", name = "Standard UK Shipping",
marketplaceId = "EBAY_GB", marketplaceId = "EBAY_GB",
@@ -278,52 +226,22 @@ public class EbayListingService
{ {
sortOrder = 1, sortOrder = 1,
shippingCarrierCode = "ROYALMAIL", shippingCarrierCode = "ROYALMAIL",
shippingServiceCode = "UK_OtherCourier", shippingServiceCode = "UK_OtherCourier",
shippingCost = new { value = "2.85", currency = "GBP" }, shippingCost = new { value = "2.85", currency = "GBP" },
additionalShippingCost = new { value = "0.00", currency = "GBP" } additionalShippingCost = new { value = "0.00", currency = "GBP" }
} }
} }
} }
} }
}; }),
using var req = MakeRequest(HttpMethod.Post, TryCreatePolicyAsync(token, "payment_policy", new
$"{_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
{ {
name = "Managed Payments", name = "Managed Payments",
marketplaceId = "EBAY_GB", marketplaceId = "EBAY_GB",
categoryTypes = new[] { new { name = "ALL_EXCLUDING_MOTORS_VEHICLES" } }, categoryTypes = new[] { new { name = "ALL_EXCLUDING_MOTORS_VEHICLES" } },
paymentMethods = Array.Empty<object>() paymentMethods = Array.Empty<object>()
}; }),
using var req = MakeRequest(HttpMethod.Post, TryCreatePolicyAsync(token, "return_policy", new
$"{_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
{ {
name = "Standard Returns", name = "Standard Returns",
marketplaceId = "EBAY_GB", marketplaceId = "EBAY_GB",
@@ -332,22 +250,29 @@ public class EbayListingService
returnPeriod = new { value = 30, unit = "DAY" }, returnPeriod = new { value = 30, unit = "DAY" },
refundMethod = "MONEY_BACK", refundMethod = "MONEY_BACK",
returnShippingCostPayer = "BUYER" returnShippingCostPayer = "BUYER"
}; })
);
}
private async Task TryCreatePolicyAsync(string token, string policyType, object body)
{
try
{
using var req = MakeRequest(HttpMethod.Post, 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( req.Content = new StringContent(
JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json"); JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json");
var res2 = await _http.SendAsync(req); var res = await _http.SendAsync(req);
var body2 = await res2.Content.ReadAsStringAsync(); var respBody = await res.Content.ReadAsStringAsync();
LogSetup($"return_policy create → {(int)res2.StatusCode} {body2}"); 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) private async Task CreateMerchantLocationAsync(string token, string postcode)
{ {
if (string.IsNullOrWhiteSpace(postcode)) if (string.IsNullOrWhiteSpace(postcode))
postcode = "N/A"; // eBay allows this when postcode is genuinely unknown postcode = "N/A";
var body = new var body = new
{ {
@@ -355,8 +280,8 @@ public class EbayListingService
{ {
address = new { postalCode = postcode, country = "GB" } address = new { postalCode = postcode, country = "GB" }
}, },
locationTypes = new[] { "WAREHOUSE" }, locationTypes = new[] { "WAREHOUSE" },
name = "Home", name = "Home",
merchantLocationStatus = "ENABLED" merchantLocationStatus = "ENABLED"
}; };
@@ -383,10 +308,8 @@ public class EbayListingService
{ {
shipToLocationAvailability = new { quantity = draft.Quantity } shipToLocationAvailability = new { quantity = draft.Quantity }
}, },
condition = draft.ConditionId, condition = draft.ConditionId,
conditionDescription = draft.Condition == ItemCondition.Used ? "Used see photos" : null, conditionDescription = draft.Condition == ItemCondition.Used ? "Used \u2014 see photos" : null,
description = draft.Description,
title = draft.Title,
product = new product = new
{ {
title = draft.Title, 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)}"; var url = $"{_auth.BaseUrl}/sell/inventory/v1/inventory_item/{Uri.EscapeDataString(draft.Sku)}";
using var req = MakeRequest(HttpMethod.Put, url, token); 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"); req.Content.Headers.Add("Content-Language", "en-GB");
var res = await _http.SendAsync(req); var res = await _http.SendAsync(req);
@@ -432,24 +350,16 @@ public class EbayListingService
paymentPolicyId = _paymentPolicyId, paymentPolicyId = _paymentPolicyId,
returnPolicyId = _returnPolicyId returnPolicyId = _returnPolicyId
}, },
pricingSummary = new pricingSummary = new { price = new { value = draft.Price.ToString("F2"), currency = "GBP" } },
{
price = new { value = draft.Price.ToString("F2"), currency = "GBP" }
},
merchantLocationKey = _merchantLocationKey, 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, using var req = MakeRequest(HttpMethod.Post,
$"{_auth.BaseUrl}/sell/inventory/v1/offer", token); $"{_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(); var responseJson = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode) if (!res.IsSuccessStatusCode)
@@ -481,29 +391,22 @@ public class EbayListingService
private async Task<List<string>> UploadPhotosAsync(List<string> photoPaths, string token) private async Task<List<string>> UploadPhotosAsync(List<string> photoPaths, string token)
{ {
var urls = new List<string>(); if (photoPaths.Count == 0) return [];
if (photoPaths.Count == 0) return urls;
var tradingBase = _auth.BaseUrl.Contains("sandbox") var tradingBase = _auth.BaseUrl.Contains("sandbox")
? "https://api.sandbox.ebay.com/ws/api.dll" ? "https://api.sandbox.ebay.com/ws/api.dll"
: "https://api.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; await semaphore.WaitAsync();
try try { return await UploadSinglePhotoAsync(path, tradingBase, token); }
{ catch { return null; }
var url = await UploadSinglePhotoAsync(path, tradingBase, token); finally { semaphore.Release(); }
if (!string.IsNullOrEmpty(url)) });
urls.Add(url);
}
catch
{
// Skip failed photos; don't abort the whole listing
}
}
return urls; return [.. (await Task.WhenAll(tasks)).Where(u => !string.IsNullOrEmpty(u))];
} }
private async Task<string?> UploadSinglePhotoAsync(string filePath, string tradingUrl, string token) private async Task<string?> UploadSinglePhotoAsync(string filePath, string tradingUrl, string token)
@@ -522,7 +425,6 @@ public class EbayListingService
</UploadSiteHostedPicturesRequest> </UploadSiteHostedPicturesRequest>
"""; """;
// Use HttpRequestMessage with _photoHttp so we don't create a new socket per photo
using var content = new MultipartFormDataContent(); using var content = new MultipartFormDataContent();
content.Add(new StringContent(soapBody, Encoding.UTF8, "text/xml"), "XML Payload"); content.Add(new StringContent(soapBody, Encoding.UTF8, "text/xml"), "XML Payload");
var imageContent = new ByteArrayContent(fileBytes); var imageContent = new ByteArrayContent(fileBytes);
@@ -546,7 +448,6 @@ public class EbayListingService
// ---- Helpers ---- // ---- Helpers ----
/// <summary>Creates a pre-authorised request targeting the eBay REST APIs.</summary>
private HttpRequestMessage MakeRequest(HttpMethod method, string url, string token) private HttpRequestMessage MakeRequest(HttpMethod method, string url, string token)
{ {
var req = new HttpRequestMessage(method, url); var req = new HttpRequestMessage(method, url);
@@ -555,8 +456,3 @@ public class EbayListingService
return req; return req;
} }
} }