Files
EbayListingTool/EbayListingTool/Services/EbayListingService.cs
Peter Foster d9072a6018 fix: Content-Language header on offer creation; double photo thumbnails; sync Windows diverged files
- 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>
2026-04-17 12:59:15 +01:00

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;
}
}