- Add Content-Language: en-US to CreateOfferAsync (eBay Inventory API requires it) - Double thumbnail sizes in NewListingView (96→192, 100→200) - Sync NewListingView.xaml/.cs from Windows (replaces SingleItemView) - Sync latest AiAssistantService, SavedListingsService, MainWindow, SavedListingsView from Windows Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
486 lines
19 KiB
C#
486 lines
19 KiB
C#
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Text;
|
|
using EbayListingTool.Models;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Linq;
|
|
|
|
namespace EbayListingTool.Services;
|
|
|
|
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;
|
|
|
|
private readonly Dictionary<string, string> _policyCache = new();
|
|
private static readonly string PolicyCacheFile =
|
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"EbayListingTool", "fulfillment_policies.json");
|
|
|
|
public EbayListingService(EbayAuthService auth, EbayCategoryService categoryService)
|
|
{
|
|
_auth = auth;
|
|
_categoryService = categoryService;
|
|
LoadPolicyCacheFromDisk();
|
|
}
|
|
|
|
/// <summary>Call when the user disconnects so stale IDs are not reused after re-login.</summary>
|
|
public void ClearCache()
|
|
{
|
|
_paymentPolicyId = null;
|
|
_returnPolicyId = null;
|
|
_merchantLocationKey = null;
|
|
}
|
|
|
|
private void LoadPolicyCacheFromDisk()
|
|
{
|
|
try
|
|
{
|
|
if (!File.Exists(PolicyCacheFile)) return;
|
|
var json = File.ReadAllText(PolicyCacheFile);
|
|
var dict = JsonConvert.DeserializeObject<Dictionary<string, string>>(json);
|
|
if (dict != null)
|
|
foreach (var kv in dict) _policyCache[kv.Key] = kv.Value;
|
|
}
|
|
catch { /* ignore corrupt cache */ }
|
|
}
|
|
|
|
private void SavePolicyCacheToDisk()
|
|
{
|
|
try
|
|
{
|
|
Directory.CreateDirectory(Path.GetDirectoryName(PolicyCacheFile)!);
|
|
File.WriteAllText(PolicyCacheFile, JsonConvert.SerializeObject(_policyCache));
|
|
}
|
|
catch { /* non-critical */ }
|
|
}
|
|
|
|
public async Task<string> PostListingAsync(ListingDraft draft)
|
|
{
|
|
var token = await _auth.GetValidAccessTokenAsync();
|
|
|
|
// Resolve business policies and merchant location before touching inventory/offers
|
|
await EnsurePoliciesAndLocationAsync(token, draft.Postcode);
|
|
_fulfillmentPolicyId = await GetOrCreateFulfillmentPolicyAsync(draft.Postage, draft.ShippingCost, token);
|
|
|
|
// 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)
|
|
?? throw new InvalidOperationException($"Could not find category for: {draft.CategoryName}");
|
|
}
|
|
|
|
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;
|
|
var domain = _auth.BaseUrl.Contains("sandbox") ? "sandbox.ebay.co.uk" : "ebay.co.uk";
|
|
draft.EbayListingUrl = $"https://www.{domain}/itm/{itemId}";
|
|
|
|
return draft.EbayListingUrl;
|
|
}
|
|
|
|
// ---- Fulfillment policy: on-demand creation ----
|
|
|
|
private static string ToShippingServiceCode(PostageOption option) => option switch
|
|
{
|
|
PostageOption.RoyalMailFirstClass => "UK_RoyalMailFirstClassStandard",
|
|
PostageOption.RoyalMailSecondClass => "UK_RoyalMailSecondClassStandard",
|
|
PostageOption.RoyalMailTracked24 => "UK_RoyalMailTracked24",
|
|
PostageOption.RoyalMailTracked48 => "UK_RoyalMailTracked48",
|
|
PostageOption.CollectionOnly => "UK_CollectInPerson",
|
|
PostageOption.FreePostage => "UK_RoyalMailSecondClassStandard",
|
|
_ => "UK_RoyalMailSecondClassStandard"
|
|
};
|
|
|
|
private async Task<string> GetOrCreateFulfillmentPolicyAsync(
|
|
PostageOption postage, decimal shippingCost, string token)
|
|
{
|
|
var free = postage == PostageOption.FreePostage || postage == PostageOption.CollectionOnly;
|
|
var cost = free ? 0m : shippingCost;
|
|
var cacheKey = $"{postage}_{cost:F2}";
|
|
|
|
if (_policyCache.TryGetValue(cacheKey, out var cached)) return cached;
|
|
|
|
var serviceCode = ToShippingServiceCode(postage);
|
|
var policyName = $"ELT_{postage}_{cost:F2}".Replace(" ", "");
|
|
|
|
object shippingServiceObj;
|
|
if (postage == PostageOption.CollectionOnly)
|
|
{
|
|
shippingServiceObj = new
|
|
{
|
|
shippingServiceCode = serviceCode,
|
|
shippingCost = new { value = "0.00", currency = "GBP" },
|
|
freeShipping = false,
|
|
buyerResponsibleForShipping = true,
|
|
sortOrder = 1
|
|
};
|
|
}
|
|
else
|
|
{
|
|
shippingServiceObj = new
|
|
{
|
|
shippingCarrierCode = "RoyalMail",
|
|
shippingServiceCode = serviceCode,
|
|
shippingCost = new { value = cost.ToString("F2"), currency = "GBP" },
|
|
freeShipping = free,
|
|
sortOrder = 1
|
|
};
|
|
}
|
|
|
|
var body = new
|
|
{
|
|
name = policyName,
|
|
marketplaceId = "EBAY_GB",
|
|
categoryTypes = new[] { new { name = "ALL_EXCLUDING_MOTORS_VEHICLES" } },
|
|
handlingTime = new { value = 2, unit = "DAY" },
|
|
shippingOptions = new[]
|
|
{
|
|
new
|
|
{
|
|
optionType = "DOMESTIC",
|
|
costType = postage == PostageOption.CollectionOnly ? "NOT_SPECIFIED" : "FLAT_RATE",
|
|
shippingServices = new[] { shippingServiceObj }
|
|
}
|
|
}
|
|
};
|
|
|
|
var json = JsonConvert.SerializeObject(body,
|
|
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
|
|
|
|
using var req = MakeRequest(HttpMethod.Post,
|
|
$"{_auth.BaseUrl}/sell/account/v1/fulfillment_policy", token);
|
|
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
|
|
var res = await _http.SendAsync(req);
|
|
var resJson = await res.Content.ReadAsStringAsync();
|
|
|
|
if (!res.IsSuccessStatusCode)
|
|
throw new HttpRequestException(
|
|
$"Could not create fulfillment policy ({(int)res.StatusCode}): {resJson}");
|
|
|
|
var policyId = JObject.Parse(resJson)["fulfillmentPolicyId"]?.ToString()
|
|
?? throw new InvalidOperationException("No fulfillmentPolicyId in response.");
|
|
|
|
_policyCache[cacheKey] = policyId;
|
|
SavePolicyCacheToDisk();
|
|
return policyId;
|
|
}
|
|
|
|
// ---- 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;
|
|
|
|
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<string> imageUrls, string token)
|
|
{
|
|
var inventoryItem = new
|
|
{
|
|
availability = new
|
|
{
|
|
shipToLocationAvailability = new { quantity = draft.Quantity }
|
|
},
|
|
condition = draft.ConditionId,
|
|
conditionDescription = draft.Condition == ItemCondition.Used ? "Used — see photos" : null,
|
|
description = draft.Description,
|
|
title = draft.Title,
|
|
product = new
|
|
{
|
|
title = draft.Title,
|
|
description = draft.Description,
|
|
imageUrls = imageUrls.Count > 0 ? imageUrls : null,
|
|
aspects = draft.Aspects.Count > 0
|
|
? draft.Aspects.ToDictionary(kv => kv.Key, kv => new[] { kv.Value })
|
|
: (object?)null
|
|
}
|
|
};
|
|
|
|
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.Headers.Add("Content-Language", "en-GB");
|
|
|
|
var res = await _http.SendAsync(req);
|
|
if (!res.IsSuccessStatusCode)
|
|
{
|
|
var err = await res.Content.ReadAsStringAsync();
|
|
throw new HttpRequestException($"Failed to create inventory item: {err}");
|
|
}
|
|
}
|
|
|
|
// ---- Offer ----
|
|
|
|
private async Task<string> CreateOfferAsync(ListingDraft draft, string token)
|
|
{
|
|
var offer = new
|
|
{
|
|
sku = draft.Sku,
|
|
marketplaceId = "EBAY_GB",
|
|
format = draft.Format == ListingFormat.Auction ? "AUCTION" : "FIXED_PRICE",
|
|
availableQuantity = draft.Quantity,
|
|
categoryId = draft.CategoryId,
|
|
listingDescription = draft.Description,
|
|
listingPolicies = new
|
|
{
|
|
fulfillmentPolicyId = _fulfillmentPolicyId,
|
|
paymentPolicyId = _paymentPolicyId,
|
|
returnPolicyId = _returnPolicyId
|
|
},
|
|
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.Headers.Add("Content-Language", "en-US");
|
|
|
|
var res = await _http.SendAsync(req);
|
|
var responseJson = await res.Content.ReadAsStringAsync();
|
|
|
|
if (!res.IsSuccessStatusCode)
|
|
throw new HttpRequestException($"Failed to create offer: {responseJson}");
|
|
|
|
return JObject.Parse(responseJson)["offerId"]?.ToString()
|
|
?? throw new InvalidOperationException("No offerId in create offer response.");
|
|
}
|
|
|
|
// ---- Publish ----
|
|
|
|
private async Task<string> PublishOfferAsync(string offerId, string token)
|
|
{
|
|
using var req = MakeRequest(HttpMethod.Post,
|
|
$"{_auth.BaseUrl}/sell/inventory/v1/offer/{offerId}/publish", token);
|
|
req.Content = new StringContent("{}", Encoding.UTF8, "application/json");
|
|
|
|
var res = await _http.SendAsync(req);
|
|
var responseJson = await res.Content.ReadAsStringAsync();
|
|
|
|
if (!res.IsSuccessStatusCode)
|
|
throw new HttpRequestException($"Failed to publish offer: {responseJson}");
|
|
|
|
return JObject.Parse(responseJson)["listingId"]?.ToString()
|
|
?? throw new InvalidOperationException("No listingId in publish response.");
|
|
}
|
|
|
|
// ---- Photo upload ----
|
|
|
|
private async Task<List<string>> UploadPhotosAsync(List<string> photoPaths, string token)
|
|
{
|
|
var urls = new List<string>();
|
|
if (photoPaths.Count == 0) return urls;
|
|
|
|
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))
|
|
{
|
|
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
|
|
}
|
|
}
|
|
|
|
return urls;
|
|
}
|
|
|
|
private async Task<string?> UploadSinglePhotoAsync(string filePath, string tradingUrl, string token)
|
|
{
|
|
var fileBytes = await File.ReadAllBytesAsync(filePath);
|
|
var ext = Path.GetExtension(filePath).TrimStart('.').ToLower();
|
|
|
|
var soapBody = $"""
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<UploadSiteHostedPicturesRequest xmlns="urn:ebay:apis:eBLBaseComponents">
|
|
<RequesterCredentials>
|
|
<eBayAuthToken>{token}</eBayAuthToken>
|
|
</RequesterCredentials>
|
|
<PictureName>{Path.GetFileNameWithoutExtension(filePath)}</PictureName>
|
|
<PictureSet>Supersize</PictureSet>
|
|
</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);
|
|
imageContent.Headers.ContentType = new MediaTypeHeaderValue($"image/{ext}");
|
|
content.Add(imageContent, "image", Path.GetFileName(filePath));
|
|
|
|
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();
|
|
|
|
var match = System.Text.RegularExpressions.Regex.Match(
|
|
responseXml, @"<FullURL>(.*?)</FullURL>");
|
|
return match.Success ? match.Groups[1].Value : null;
|
|
}
|
|
|
|
// ---- 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);
|
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
|
req.Headers.Add("X-EBAY-C-MARKETPLACE-ID", "EBAY_GB");
|
|
return req;
|
|
}
|
|
}
|