feat: create fulfillment policies on-demand per postage option and cost

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Foster
2026-04-15 09:37:25 +01:00
parent a22e11b2f7
commit c9bdb6f7fe

View File

@@ -22,27 +22,56 @@ public class EbayListingService
private string? _returnPolicyId; private string? _returnPolicyId;
private string? _merchantLocationKey; 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) public EbayListingService(EbayAuthService auth, EbayCategoryService categoryService)
{ {
_auth = auth; _auth = auth;
_categoryService = categoryService; _categoryService = categoryService;
LoadPolicyCacheFromDisk();
} }
/// <summary>Call when the user disconnects so stale IDs are not reused after re-login.</summary> /// <summary>Call when the user disconnects so stale IDs are not reused after re-login.</summary>
public void ClearCache() public void ClearCache()
{ {
_fulfillmentPolicyId = null;
_paymentPolicyId = null; _paymentPolicyId = null;
_returnPolicyId = null; _returnPolicyId = null;
_merchantLocationKey = 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) public async Task<string> PostListingAsync(ListingDraft draft)
{ {
var token = await _auth.GetValidAccessTokenAsync(); var token = await _auth.GetValidAccessTokenAsync();
// Resolve business policies and merchant location before touching inventory/offers // Resolve business policies and merchant location before touching inventory/offers
await EnsurePoliciesAndLocationAsync(token, draft.Postcode); await EnsurePoliciesAndLocationAsync(token, draft.Postcode);
_fulfillmentPolicyId = await GetOrCreateFulfillmentPolicyAsync(draft.Postage, draft.ShippingCost, token);
// 1. Upload photos and get eBay-hosted URLs // 1. Upload photos and get eBay-hosted URLs
var imageUrls = await UploadPhotosAsync(draft.PhotoPaths, token); var imageUrls = await UploadPhotosAsync(draft.PhotoPaths, token);
@@ -73,6 +102,94 @@ public class EbayListingService
return draft.EbayListingUrl; 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 ---- // ---- Setup: policies + location ----
/// <summary> /// <summary>
@@ -84,28 +201,6 @@ public class EbayListingService
{ {
var baseUrl = _auth.BaseUrl; var baseUrl = _auth.BaseUrl;
if (_fulfillmentPolicyId == null)
{
using var req = MakeRequest(HttpMethod.Get,
$"{baseUrl}/sell/account/v1/fulfillment_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 fulfillment policies ({(int)res.StatusCode}): {json}");
var arr = JObject.Parse(json)["fulfillmentPolicies"] as JArray;
_fulfillmentPolicyId = arr?.Count > 0
? arr[0]["fulfillmentPolicyId"]?.ToString()
: null;
if (_fulfillmentPolicyId == null)
throw new InvalidOperationException(
"No fulfillment policy found on your eBay account.\n\n" +
"Please set one up in My eBay → Account → Business policies, then try again.");
}
if (_paymentPolicyId == null) if (_paymentPolicyId == null)
{ {
using var req = MakeRequest(HttpMethod.Get, using var req = MakeRequest(HttpMethod.Get,