refactor: unify TryCreate* into one helper, parallel photo upload, static JsonSettings, remove dead ExtractEbayError
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user