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.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,13 +63,8 @@ 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);
draft.EbayItemId = itemId;
@@ -77,11 +76,6 @@ public class EbayListingService
// ---- 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)
{
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)
@@ -185,7 +179,6 @@ public class EbayListingService
: 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
}
}
/// <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)
{
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",
@@ -285,45 +233,15 @@ public class EbayListingService
}
}
}
};
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" } },
paymentMethods = Array.Empty<object>()
};
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
{
@@ -384,9 +309,7 @@ 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,
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,22 +350,14 @@ 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 }
};
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 responseJson = await res.Content.ReadAsStringAsync();
@@ -481,29 +391,22 @@ public class EbayListingService
private async Task<List<string>> UploadPhotosAsync(List<string> photoPaths, string token)
{
var urls = new List<string>();
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<string?> UploadSinglePhotoAsync(string filePath, string tradingUrl, string token)
@@ -522,7 +425,6 @@ public class EbayListingService
</UploadSiteHostedPicturesRequest>
""";
// 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 ----
/// <summary>Creates a pre-authorised request targeting the eBay REST APIs.</summary>
private HttpRequestMessage MakeRequest(HttpMethod method, string url, string token)
{
var req = new HttpRequestMessage(method, url);
@@ -555,8 +456,3 @@ public class EbayListingService
return req;
}
}