Compare commits

..

1 Commits

Author SHA1 Message Date
Chukwuleta Tobechi
3c3abaf1e1 Configuration to handle restock when Invoice expires 2025-11-13 14:40:46 +01:00
17 changed files with 165 additions and 744 deletions

View File

@ -1,139 +0,0 @@
language: en-US
tone_instructions: ''
early_access: false
enable_free_tier: true
reviews:
summaries:
include_walkthrough: false
profile: chill
request_changes_workflow: false
high_level_summary: false
high_level_summary_placeholder: '@coderabbitai summary'
high_level_summary_in_walkthrough: false
auto_title_placeholder: '@coderabbitai'
auto_title_instructions: ''
review_status: true
commit_status: true
fail_commit_status: false
collapse_walkthrough: false
changed_files_summary: false
sequence_diagrams: false
assess_linked_issues: true
related_issues: true
related_prs: true
suggested_labels: true
auto_apply_labels: false
suggested_reviewers: true
auto_assign_reviewers: false
poem: false
labeling_instructions: []
path_filters: []
path_instructions: []
abort_on_close: true
pr_description: false
auto_review:
enabled: true
auto_incremental_review: true
ignore_title_keywords: []
labels: []
drafts: false
base_branches: []
finishing_touches:
docstrings:
enabled: false
tools:
ast-grep:
rule_dirs: []
util_dirs: []
essential_rules: true
packages: []
shellcheck:
enabled: true
ruff:
enabled: true
markdownlint:
enabled: true
github-checks:
enabled: true
timeout_ms: 90000
languagetool:
enabled: true
enabled_rules: []
disabled_rules: []
enabled_categories: []
disabled_categories: []
enabled_only: false
level: default
biome:
enabled: true
hadolint:
enabled: true
swiftlint:
enabled: true
phpstan:
enabled: true
level: default
golangci-lint:
enabled: true
yamllint:
enabled: true
gitleaks:
enabled: true
checkov:
enabled: true
detekt:
enabled: true
eslint:
enabled: true
rubocop:
enabled: true
buf:
enabled: true
regal:
enabled: true
actionlint:
enabled: true
pmd:
enabled: true
cppcheck:
enabled: true
semgrep:
enabled: true
circleci:
enabled: true
sqlfluff:
enabled: true
prismaLint:
enabled: true
oxc:
enabled: true
shopifyThemeCheck:
enabled: true
chat:
auto_reply: true
create_issues: true
integrations:
jira:
usage: auto
linear:
usage: auto
knowledge_base:
opt_out: false
web_search:
enabled: true
learnings:
scope: auto
issues:
scope: auto
jira:
usage: auto
project_keys: []
linear:
usage: auto
team_keys: []
pull_requests:
scope: auto
code_generation:
docstrings:
language: en-US
path_instructions: []

View File

@ -7,7 +7,7 @@
<PropertyGroup>
<Product>BTCPay Server Shopify Plugin v2</Product>
<Description>BTCPay server integration with Shopify.</Description>
<Version>1.1.2</Version>
<Version>1.0.8</Version>
</PropertyGroup>
<!-- Plugin development properties -->

View File

@ -13,7 +13,7 @@ using Newtonsoft.Json.Serialization;
using BTCPayServer.Plugins.ShopifyPlugin.JsonConverters;
using Newtonsoft.Json.Converters;
using Microsoft.AspNetCore.WebUtilities;
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
using JArray = Newtonsoft.Json.Linq.JArray;
namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
{
@ -73,9 +73,9 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
return JsonConvert.DeserializeObject<AccessTokenResponse>(strResp);
}
public bool VerifyWebhookSignature(string body, string hmac, string webhookSecret)
public bool VerifyWebhookSignature(string body, string hmac)
{
var keyBytes = Encoding.UTF8.GetBytes(webhookSecret);
var keyBytes = Encoding.UTF8.GetBytes(_credentials.ClientSecret);
using (var hmacObj = new HMACSHA256(keyBytes))
{
var hashBytes = hmacObj.ComputeHash(Encoding.UTF8.GetBytes(body));
@ -235,18 +235,6 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
name
cancelledAt
statusPageUrl
customer {
displayName
defaultEmailAddress {
emailAddress
}
}
btcpayInvoiceId: metafield(
namespace: "custom"
key: "btcpay_invoice_id"
) {
value
}
totalOutstandingSet {
shopMoney {
amount
@ -294,7 +282,7 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
""";
public async Task<ShopifyOrder> GetOrder(long orderId, bool withTransactions = false)
{
// https://shopify.dev/docs/api/admin-graphql/2026-01/queries/order
// https://shopify.dev/docs/api/admin-graphql/2024-10/queries/order
var req = """
query getOrderDetails($orderId: ID!, $includeTxs: Boolean!) {
order(id: $orderId) {
@ -313,30 +301,12 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
return d?.ToObject<ShopifyOrder>(JsonSerializer);
}
public async Task<List<string>> GetGrantedAccessScopes()
{
// https://shopify.dev/docs/api/admin-graphql/latest/queries/currentAppInstallation
var req = """
query GetAccessScopes {
currentAppInstallation {
accessScopes {
handle
}
}
}
""";
var resp = await SendGraphQL(req, new JObject());
var d = Unwrap(resp, "currentAppInstallation");
var accessScopes = d?["accessScopes"]?.ToObject<List<AccessScopeHandle>>(JsonSerializer);
return accessScopes?.Select(s => s.Handle).ToList() ?? new List<string>();
}
private HttpRequestMessage CreateGraphQLRequest(string req, JObject variables = null)
private HttpRequestMessage CreateGraphQLRequest(string req, JObject variables = null)
{
var jobj = new JObject() { ["query"] = req };
if (variables is not null)
jobj.Add("variables", variables);
return new HttpRequestMessage(HttpMethod.Post, $"{_shopUrl}/admin/api/2026-01/graphql.json")
return new HttpRequestMessage(HttpMethod.Post, $"{_shopUrl}/admin/api/2024-10/graphql.json")
{
Content = new StringContent(jobj.ToString(), Encoding.UTF8, "application/json")
};
@ -460,6 +430,7 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
var d = Unwrap(respObj, "orderUpdate");
return ShopifyId.Parse(d["order"]["id"].Value<string>());
}
}

View File

@ -49,11 +49,7 @@ public class OrderTransaction
}
public class ShopifyOrder
{
public ShopifyCustomer Customer { get; set; }
[JsonProperty("btcpayInvoiceId")]
public ShopifyMetafield BtcpayInvoiceId { get; set; }
public string StatusPageUrl { get; set; }
public string StatusPageUrl { get; set; }
public ShopifyId Id { get; set; }
public string Name { get; set; }
public DateTimeOffset? CancelledAt { get; set; }
@ -72,16 +68,3 @@ public class ShopifyMoney
[JsonConverter(typeof(BTCPayServer.JsonConverters.NumericStringJsonConverter))]
public decimal Amount { get; set; }
}
public class ShopifyCustomer
{
public string DisplayName { get; set; }
public ShopifyCustomerEmail DefaultEmailAddress { get; set; }
}
public class ShopifyCustomerEmail
{
public string EmailAddress { get; set; }
}
public class ShopifyMetafield
{
public string Value { get; set; }
}

View File

@ -1,45 +1,33 @@
#nullable enable
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning.LndHub;
using BTCPayServer.Payouts;
using BTCPayServer.Plugins.Emails;
using BTCPayServer.Plugins.Emails.Controllers;
using BTCPayServer.Plugins.Emails.HostedServices;
using BTCPayServer.Plugins.Emails.Services;
using BTCPayServer.Plugins.ShopifyPlugin.Clients;
using BTCPayServer.Plugins.ShopifyPlugin.Services;
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
using BTCPayServer.Rating;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.CodeAnalysis;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;
using NBitcoin;
using Newtonsoft.Json;
using System.Linq;
using Microsoft.AspNetCore.Http;
using BTCPayServer.Abstractions.Extensions;
using System;
using Microsoft.AspNetCore.Authorization;
using BTCPayServer.Controllers;
using BTCPayServer.Client;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Plugins.ShopifyPlugin.Services;
using BTCPayServer.Client.Models;
using Newtonsoft.Json.Linq;
using Microsoft.Extensions.Primitives;
using System.IO;
using System.Text;
using StoreData = BTCPayServer.Data.StoreData;
using BTCPayServer.Abstractions.Models;
using System.Text.RegularExpressions;
using System.Globalization;
using BTCPayServer.Lightning.LndHub;
using System.Threading;
using BTCPayServer.Filters;
using BTCPayServer.Plugins.ShopifyPlugin.Clients;
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
using Microsoft.Extensions.Configuration;
using Microsoft.AspNetCore.Cors;
namespace BTCPayServer.Plugins.ShopifyPlugin;
@ -47,46 +35,23 @@ namespace BTCPayServer.Plugins.ShopifyPlugin;
[AutoValidateAntiforgeryToken]
public class UIShopifyV2Controller : Controller
{
private readonly RateFetcher _rateProvider;
private readonly StoreRepository _storeRepo;
private readonly EventAggregator _eventAggregator;
private readonly CurrencyNameTable _currencyNameTable;
private readonly DefaultRulesCollection _defaultRules;
private readonly InvoiceRepository _invoiceRepository;
private readonly EmailSenderFactory _emailSenderFactory;
private readonly UIInvoiceController _invoiceController;
private readonly PayoutMethodHandlerDictionary _payoutHandlers;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly PullPaymentHostedService _paymentHostedService;
private readonly InvoiceRepository _invoiceRepository;
private readonly UIInvoiceController _invoiceController;
public UIShopifyV2Controller
(StoreRepository storeRepo,
RateFetcher rateProvider,
IConfiguration configuration,
EventAggregator eventAggregator,
CurrencyNameTable currencyNameTable,
InvoiceRepository invoiceRepository,
DefaultRulesCollection defaultRules,
UIInvoiceController invoiceController,
EmailSenderFactory emailSenderFactory,
ShopifyClientFactory shopifyClientFactory,
PayoutMethodHandlerDictionary payoutHandlers,
ApplicationDbContextFactory dbContextFactory,
PullPaymentHostedService paymentHostedService)
public UIShopifyV2Controller
(
ShopifyClientFactory shopifyClientFactory,
StoreRepository storeRepo,
UIInvoiceController invoiceController,
IConfiguration configuration,
InvoiceRepository invoiceRepository)
{
_storeRepo = storeRepo;
_rateProvider = rateProvider;
_defaultRules = defaultRules;
_payoutHandlers = payoutHandlers;
_eventAggregator = eventAggregator;
_dbContextFactory = dbContextFactory;
_currencyNameTable = currencyNameTable;
_invoiceRepository = invoiceRepository;
ShopifyClientFactory = shopifyClientFactory;
_invoiceRepository = invoiceRepository;
_invoiceController = invoiceController;
_emailSenderFactory = emailSenderFactory;
ShopifyClientFactory = shopifyClientFactory;
_paymentHostedService = paymentHostedService;
}
}
public StoreData CurrentStore => HttpContext.GetStoreData();
public ShopifyClientFactory ShopifyClientFactory { get; }
@ -249,8 +214,8 @@ public class UIShopifyV2Controller : Controller
return RedirectToAction(nameof(Settings), new { storeId });
}
else // (command is null)
{
return View("/Views/UIShopify/Settings.cshtml", new ShopifySettingsViewModel()
{
return View("/Views/UIShopify/Settings.cshtml", new ShopifySettingsViewModel()
{
ClientId = settings.Setup?.ClientId,
ClientSecret = settings.Setup?.ClientSecret,
@ -260,106 +225,64 @@ public class UIShopifyV2Controller : Controller
AppDeployed = settings.Setup is { DeployedCommit: {} },
AppInstalled = settings.Setup is { AccessToken: {} },
AppName = settings.PreferredAppName ?? ShopifyStoreSettings.DefaultAppName,
Step = ShopifySetupStep(settings)
});
Step = settings switch
{
{ Setup: null } or { Setup: { ClientId: null, ClientSecret: null } } => ShopifySettingsViewModel.State.WaitingClientCreds,
{ Setup: { DeployedCommit: null } } => ShopifySettingsViewModel.State.WaitingForDeploy,
{ Setup: { AccessToken: null } } => ShopifySettingsViewModel.State.WaitingForInstall,
_ => ShopifySettingsViewModel.State.Done
}
});
}
}
private ShopifySettingsViewModel.State ShopifySetupStep(ShopifyStoreSettings settings)
{
return settings switch
{
{ Setup: null } or { Setup: { ClientId: null, ClientSecret: null } } => ShopifySettingsViewModel.State.WaitingClientCreds,
{ Setup: { DeployedCommit: null } } => ShopifySettingsViewModel.State.WaitingForDeploy,
{ Setup: { AccessToken: null } } => ShopifySettingsViewModel.State.WaitingForInstall,
_ => ShopifySettingsViewModel.State.Done
};
}
[HttpGet("~/stores/{storeId}/plugins/shopify-v2/refunds/settings")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)]
public async Task<IActionResult> RefundSettings(string storeId)
[HttpGet("~/stores/{storeId}/plugins/shopify-v2/configuration")]
public async Task<IActionResult> Configuration(string storeId)
{
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName) ?? new();
settings.Setup ??= new();
var setupState = ShopifySetupStep(settings);
if (setupState != ShopifySettingsViewModel.State.Done)
if (settings.Setup is { DeployedCommit: null, AccessToken: null })
{
this.TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "You need to complete your plugin settings setup first",
Severity = StatusMessageModel.StatusSeverity.Warning
});
return RedirectToAction(nameof(Settings), new { storeId });
}
var client = await this.ShopifyClientFactory.CreateAPIClient(storeId);
if (client is null)
{
this.TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Shopify plugin isn't configured properly",
Message = "Kindly complete your Shopify setup",
Severity = StatusMessageModel.StatusSeverity.Error
});
return RedirectToAction(nameof(Settings), new { storeId });
return RedirectToAction(nameof(Index), new { storeId });
}
var shopifyAppScopes = await client.GetGrantedAccessScopes();
var hasRequiredScope = ShopifyHostedService.HasRequiredShopifyScopes(shopifyAppScopes);
if (!hasRequiredScope)
{
this.TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "The Shopify plugin and app is missing required permissions for refunds. Kindly upgrade the shopify fragment to the latest and redeploy the app (Step 2)",
Severity = StatusMessageModel.StatusSeverity.Error
});
return RedirectToAction(nameof(Settings), new { storeId });
}
var emailSender = await _emailSenderFactory.GetEmailSender(storeId);
var isEmailSettingsConfigured = (await emailSender.GetEmailSettings() ?? new EmailSettings()).IsComplete();
ViewData["StoreEmailSettingsConfigured"] = isEmailSettingsConfigured;
if (!isEmailSettingsConfigured)
{
this.TempData.SetStatusMessageModel(new StatusMessageModel()
{
Html = $"Kindly <a href='{Url.Action(action: nameof(UIStoresEmailController.StoreEmailSettings), controller: "UIStoresEmail",
values: new { area = EmailsPlugin.Area, storeId })}' class='alert-link'>configure Email SMTP</a> to configure shopify refunds",
Severity = StatusMessageModel.StatusSeverity.Warning
});
}
Enum.TryParse<ShopifyRefundWebhookSettingsViewModel.RefundOption>(settings.Setup?.SelectedRefundOption, ignoreCase: true, out var refundOption);
var vm = new ShopifyRefundWebhookSettingsViewModel
return View("/Views/UIShopify/Configuration.cshtml", new ShopifySettingsViewModel()
{
RestockOnInvoiceExpired = settings.RestockOnInvoiceExpired,
ClientId = settings.Setup?.ClientId,
ClientSecret = settings.Setup?.ClientSecret,
ShopUrl = settings.Setup?.ShopUrl,
ShopName = GetShopName(settings.Setup?.ShopUrl),
WebhookUrl = Url.Action(nameof(Webhook), "UIShopifyV2", new { storeId }, Request.Scheme),
WebhookSecret = settings.Setup?.WebhookSecret,
SpreadPercentage = settings.Setup.SpreadPercentage,
SelectedRefundOption = refundOption
};
return View("/Views/UIShopify/RefundSettings.cshtml", vm);
AppName = settings.PreferredAppName ?? ShopifyStoreSettings.DefaultAppName,
});
}
[HttpPost("~/stores/{storeId}/plugins/shopify-v2/refunds/settings")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RefundSettings(string storeId, ShopifyRefundWebhookSettingsViewModel vm)
[HttpPost("~/stores/{storeId}/plugins/shopify-v2/configuration")]
public async Task<IActionResult> Configuration(ShopifySettingsViewModel model)
{
if (!ModelState.IsValid)
if (CurrentStore.Id == null) return NotFound();
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(CurrentStore.Id, ShopifyStoreSettings.SettingsName) ?? new();
if (settings.Setup is { DeployedCommit: null, AccessToken: null })
{
vm.WebhookUrl = Url.Action(nameof(Webhook), "UIShopifyV2", new { storeId }, Request.Scheme);
return View("/Views/UIShopify/RefundSettings.cshtml", vm);
this.TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Kindly complete your Shopify setup",
Severity = StatusMessageModel.StatusSeverity.Error
});
return RedirectToAction(nameof(Settings), new { storeId = CurrentStore.Id });
}
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName) ?? new();
settings.Setup ??= new();
settings.Setup.WebhookSecret = vm.WebhookSecret;
settings.Setup.SpreadPercentage = vm.SpreadPercentage;
settings.Setup.SelectedRefundOption = vm.SelectedRefundOption.ToString();
await _storeRepo.UpdateSetting<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName, settings);
settings.RestockOnInvoiceExpired = model.RestockOnInvoiceExpired;
await _storeRepo.UpdateSetting(CurrentStore.Id, ShopifyStoreSettings.SettingsName, settings);
this.TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Refund settings saved successfully",
Message = "Shopify configuration updated successfully",
Severity = StatusMessageModel.StatusSeverity.Success
});
return RedirectToAction(nameof(RefundSettings), new { storeId });
return RedirectToAction(nameof(Settings), new { storeId = CurrentStore.Id });
}
static AsyncDuplicateLock OrderLocks = new AsyncDuplicateLock();
@ -448,15 +371,8 @@ public class UIShopifyV2Controller : Controller
Key = "btcpay_checkout_url",
Type = "single_line_text_field",
Value = Url.Action(nameof(Checkout), "UIShopifyV2", new { storeId, checkout_token }, Request.Scheme)
},
new()
{
Namespace = "custom",
Key = "btcpay_invoice_id",
Type = "single_line_text_field",
Value = invoice.Id
}
]
}
]
});
return redirect ? RedirectToInvoiceCheckout(invoice.Id) : Ok();
}
@ -484,176 +400,43 @@ public class UIShopifyV2Controller : Controller
return null;
return o.ToString();
}
if (GetHeader("X-Shopify-Hmac-SHA256") is string hmac && GetHeader("X-Shopify-Topic") is string topic)
return new WebhookInfo(hmac, topic);
if (GetHeader("X-Shopify-Hmac-SHA256") is string hmac &&
GetHeader("X-Shopify-Topic") is string topic &&
GetHeader("X-Shopify-Sub-Topic") is string subtopic)
return new WebhookInfo(hmac, $"{topic}/{subtopic}");
return null;
}
static readonly AsyncDuplicateLock RefundLocks = new AsyncDuplicateLock();
[AllowAnonymous]
[AllowAnonymous]
[IgnoreAntiforgeryToken]
[HttpPost("~/stores/{storeId}/plugins/shopify-v2/webhooks")]
// Handles refunds, plus all forms of webhook... plus shopify requires to still listen to it...
// We actually do not use it, but shopify requires to still listen to it...
// leaving it here.
public async Task<IActionResult> Webhook(string storeId)
{
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName);
string requestBody;
using (var reader = new StreamReader(Request.Body, Encoding.UTF8))
{
requestBody = await reader.ReadToEndAsync();
}
var webhookInfo = GetWebhookInfoFromHeader(Request);
if (webhookInfo is null)
return BadRequest("Missing webhook info in HTTP headers");
{
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName);
string requestBody;
using (var reader = new StreamReader(Request.Body, Encoding.UTF8))
{
requestBody = await reader.ReadToEndAsync();
}
var webhookInfo = GetWebhookInfoFromHeader(Request);
if (webhookInfo is null)
return BadRequest("Missing webhook info in HTTP headers");
if (string.IsNullOrEmpty(settings?.Setup?.WebhookSecret))
return BadRequest("Webhook secret not saved yet");
var client = await this.ShopifyClientFactory.CreateAppClient(storeId);
if (client is null)
return NotFound();
if (!client.VerifyWebhookSignature(requestBody, webhookInfo.HMac))
return Unauthorized("Invalid HMAC signature");
var client = await this.ShopifyClientFactory.CreateAppClient(storeId);
if (client is null)
return NotFound();
if (!client.VerifyWebhookSignature(requestBody, webhookInfo.HMac, settings.Setup.WebhookSecret))
return Unauthorized("Invalid HMAC signature");
// https://shopify.dev/docs/api/webhooks?reference=toml#list-of-topics-orders/create
//if (webhookInfo.FullTopicName == "orders/create")
//{
// var order = JsonConvert.DeserializeObject<dynamic>(requestBody)!;
// checkoutTokens.Add(new(storeId, (string)order.checkout_token), (long)order.id);
//}
// https://shopify.dev/docs/api/webhooks?reference=toml#list-of-topics-orders/create
//if (webhookInfo.FullTopicName == "orders/create")
//{
// var order = JsonConvert.DeserializeObject<dynamic>(requestBody)!;
// checkoutTokens.Add(new(storeId, (string)order.checkout_token), (long)order.id);
//}
if (webhookInfo.FullTopicName == "refunds/create")
{
return await HandleRefundCreateWebhook(storeId, requestBody, settings);
}
return Ok();
}
private async Task<IActionResult> HandleRefundCreateWebhook(string storeId, string requestBody, ShopifyStoreSettings settings)
{
var store = await _storeRepo.FindStore(storeId);
var client = await this.ShopifyClientFactory.CreateAPIClient(storeId);
if (settings?.Setup == null || store == null || client == null)
return BadRequest("Store isn't registered or refunds isn't configured with shopify plugin");
// https://shopify.dev/docs/api/webhooks/latest?accordionItem=webhooks-refunds-create&reference=toml
var refundPayload = JsonConvert.DeserializeObject<ShopifyRefundWebhook>(requestBody);
if (refundPayload == null || refundPayload.OrderId <= 0)
return BadRequest("Invalid refund payload");
// refund_line_items contains the actual product to be refunded refunds
var lineItemsRefundAmount = refundPayload.RefundLineItems.Sum(item => Math.Abs(item.Subtotal));
// order_adjustments contains shipping refunds, restocking fees, and discrepancies
var adjustmentsRefundAmount = refundPayload.OrderAdjustments
.Where(adj => adj.RefundId.HasValue && adj.RefundId.Value > 0).Sum(adj => Math.Abs(adj.Amount));
var totalRefundAmount = lineItemsRefundAmount + adjustmentsRefundAmount;
if (totalRefundAmount <= 0)
return BadRequest("No valid refund amount found in order adjustments");
var emailSender = await _emailSenderFactory.GetEmailSender(storeId);
var isEmailSettingsConfigured = (await emailSender.GetEmailSettings() ?? new EmailSettings()).IsComplete();
if (!isEmailSettingsConfigured)
return BadRequest("Email Server not configured for store");
using var l = await RefundLocks.LockAsync(refundPayload.OrderId, CancellationToken.None);
if (await client.GetOrder(refundPayload.OrderId, true) is not { } order)
return BadRequest("Order is invalid");
var containsKeyword = order.PaymentGatewayNames.Any(pgName => ShopifyHostedService.IsBTCPayServerGateway(pgName));
if (!containsKeyword)
return NotFound("Order wasn't fulfilled with BTCPay Server payment option");
if (order.BtcpayInvoiceId == null || string.IsNullOrEmpty(order.BtcpayInvoiceId?.Value))
return NotFound("BTCPay invoice ID not found in order metadata");
var invoice = await _invoiceRepository.GetInvoice(order.BtcpayInvoiceId.Value);
if (invoice == null)
return BadRequest("No invoice matching this criteria");
if ((invoice.Refunds != null && invoice.Refunds.Any()) || !invoice.GetInvoiceState().CanRefund())
return BadRequest("Cannot process invoice refund at the moment, as invoice either has active refunds or invoice state cannot process refund");
var supportedPmis = _payoutHandlers.GetSupportedPayoutMethods(store)?.ToArray();
if (supportedPmis == null || !supportedPmis.Any())
return BadRequest("No supported payout methods configured for store");
var paymentMethodId = invoice.GetClosestPaymentMethodId(supportedPmis);
var paymentMethod = paymentMethodId == null ? null : invoice.GetPaymentPrompt(paymentMethodId);
if (paymentMethod?.Currency == null)
return BadRequest("Invalid payout method");
RateResult rateResult = await _rateProvider.FetchRate(new CurrencyPair(paymentMethod.Currency, invoice.Currency),
store.GetStoreBlob().GetRateRules(_defaultRules), new StoreIdRateContext(store.Id), CancellationToken.None);
if (rateResult.BidAsk == null)
return BadRequest($"Unable to fetch rate {rateResult.EvaluatedRule}");
CreatePullPaymentRequest createPullPayment = new CreatePullPaymentRequest
{
Name = $"Refund {invoice.Id}",
PayoutMethods = supportedPmis.Select(c => c.ToString()).ToArray(),
AutoApproveClaims = true,
BOLT11Expiration = store.GetStoreBlob().RefundBOLT11Expiration,
Description = $"Refund for shopify order {refundPayload.OrderId}. Amount refunded {totalRefundAmount} {invoice.Currency}",
};
switch (settings.Setup.SelectedRefundOption)
{
case "RateThen":
createPullPayment.Currency = paymentMethod.Currency;
createPullPayment.Amount = Math.Round(totalRefundAmount / paymentMethod.Rate, paymentMethod.Divisibility);
break;
case "CurrentRate":
createPullPayment.Currency = paymentMethod.Currency;
createPullPayment.Amount = Math.Round(totalRefundAmount / rateResult.BidAsk.Bid, paymentMethod.Divisibility);
break;
default:
return BadRequest("No refund option configured in plugin");
}
if (settings.Setup.SpreadPercentage is > 0 and <= 99)
{
var reduceByAmount = createPullPayment.Amount * (settings.Setup.SpreadPercentage / 100);
createPullPayment.Amount = Math.Round(createPullPayment.Amount - reduceByAmount, paymentMethod.Divisibility);
if (createPullPayment.Amount <= 0)
{
return BadRequest($"Refund amount becomes zero or negative after applying spread percentage of {settings.Setup.SpreadPercentage}%. Please reduce the spread percentage.");
}
}
await using var ctx = _dbContextFactory.CreateContext();
var ppId = await _paymentHostedService.CreatePullPayment(store, createPullPayment);
ctx.Refunds.Add(new RefundData
{
InvoiceDataId = invoice.Id,
PullPaymentDataId = ppId
});
await ctx.SaveChangesAsync();
var claimUrl = Url.Action(
action: nameof(UIPullPaymentController.ViewPullPayment),
controller: "UIPullPayment",
values: new { pullPaymentId = ppId },
protocol: Request.Scheme
);
var customer = order.Customer;
var model = new JObject
{
["RefundLink"] = claimUrl,
["Order"] = new JObject
{
["Id"] = refundPayload.OrderId.ToString()
},
["Customer"] = new JObject
{
["Email"] = customer?.DefaultEmailAddress?.EmailAddress ?? "",
["Name"] = customer?.DisplayName ?? ""
}
};
_eventAggregator.Publish(new TriggerEvent(storeId, ShopifyMailTriggers.RefundCreated, model, null));
return Ok();
}
return Ok();
}
}

View File

@ -1,7 +1,5 @@
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Plugins.Emails;
using BTCPayServer.Plugins.Emails.Views;
using BTCPayServer.Plugins.ShopifyPlugin.Services;
using Microsoft.Extensions.DependencyInjection;
@ -11,47 +9,13 @@ public class Plugin : BaseBTCPayServerPlugin
{
public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } =
{
new IBTCPayServerPlugin.PluginDependency { Identifier = nameof(BTCPayServer), Condition = ">=2.3.3" }
new IBTCPayServerPlugin.PluginDependency { Identifier = nameof(BTCPayServer), Condition = ">=2.0.0" }
};
public override void Execute(IServiceCollection services)
{
services.AddUIExtension("header-nav", "ShopifyPluginHeaderNav");
services.AddSingleton<ShopifyClientFactory>();
services.AddSingleton<ShopifyClientFactory>();
services.AddHostedService<ShopifyHostedService>();
RegisterShopifyEmailTriggers(services);
}
private void RegisterShopifyEmailTriggers(IServiceCollection services)
{
var vm = new EmailTriggerViewModel()
{
Trigger = ShopifyMailTriggers.RefundCreated,
DefaultEmail = new()
{
To = ["{Customer.Email}"],
Subject = "Refund for Shopify Order #{Order.Id}",
Body = EmailsPlugin.CreateEmail(
"Hello {Customer.Name},<br/><br/>A refund has been created for your shopify order #{Order.Id}.<br/><br/>You can claim your refund using the link below.",
"Claim Refund",
"{RefundLink}"
),
},
PlaceHolders = new()
{
new("{RefundLink}", "The link to claim the refund"),
new("{Order.Id}", "The Shopify order ID"),
new("{Customer.Email}", "The customer's email address"),
new("{Customer.Name}", "The customer's name"),
},
Description = "Shopify: Refund Created",
};
services.AddSingleton(vm);
}
}
public static class ShopifyMailTriggers
{
public const string RefundCreated = "ShopifyRefundCreated";
}

View File

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -11,6 +10,7 @@ using BTCPayServer.Logging;
using BTCPayServer.Plugins.ShopifyPlugin.Clients;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@ -18,16 +18,19 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Services;
public class ShopifyHostedService : EventHostedServiceBase
{
private readonly StoreRepository _storeRepo;
private readonly CurrencyNameTable _currencyNameTable;
private readonly InvoiceRepository _invoiceRepository;
private readonly ShopifyClientFactory shopifyClientFactory;
public ShopifyHostedService(EventAggregator eventAggregator,
public ShopifyHostedService(StoreRepository storeRepo,
EventAggregator eventAggregator,
InvoiceRepository invoiceRepository,
CurrencyNameTable currencyNameTable,
ShopifyClientFactory shopifyClientFactory,
Logs logs) : base(eventAggregator, logs)
{
_storeRepo = storeRepo;
_currencyNameTable = currencyNameTable;
_invoiceRepository = invoiceRepository;
this.shopifyClientFactory = shopifyClientFactory;
@ -72,13 +75,8 @@ public class ShopifyHostedService : EventHostedServiceBase
}
}
private static string[] _requiredAppScopes = new[] { "read_orders", "write_orders", "read_customers" };
public static bool HasRequiredShopifyScopes(IEnumerable<string> grantedScopes)
{
return _requiredAppScopes.All(required => grantedScopes.Contains(required, StringComparer.OrdinalIgnoreCase));
}
private static string[] _keywords = new[] { "bitcoin", "btc", "btcpayserver", "btcpay server" };
public static bool IsBTCPayServerGateway(string gateway)
{
return _keywords.Any(keyword => gateway.Contains(keyword, StringComparison.OrdinalIgnoreCase));
@ -88,7 +86,8 @@ public class ShopifyHostedService : EventHostedServiceBase
{
var logs = new InvoiceLogs();
var client = await shopifyClientFactory.CreateAPIClient(invoice.StoreId);
if (client is null)
var shopifySettings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(invoice.StoreId, ShopifyStoreSettings.SettingsName);
if (client is null || shopifySettings is null)
return logs;
if (await client.GetOrder(shopifyOrderId, true) is not { } order)
return logs;
@ -158,8 +157,8 @@ public class ShopifyHostedService : EventHostedServiceBase
OrderId = order.Id,
NotifyCustomer = false,
Reason = OrderCancelReason.DECLINED,
Restock = true,
Refund = false,
Restock = shopifySettings.RestockOnInvoiceExpired,
Refund = false,
StaffNote = $"BTCPay Invoice {invoice.Id} is {invoice.Status}"
});
logs.Write($"Shopify order cancelled. (Invoice Status: {invoice.Status})", InvoiceEventData.EventSeverity.Warning);

View File

@ -11,7 +11,8 @@ namespace BTCPayServer.Plugins.ShopifyPlugin
}
public const string SettingsName = "ShopifyPluginSettings";
public const string DefaultAppName = "BTCPay Server";
}
public bool RestockOnInvoiceExpired { get; set; } = true;
}
public class ShopifySetupSettings
{
@ -24,8 +25,5 @@ namespace BTCPayServer.Plugins.ShopifyPlugin
/// </summary>
public string? Version { get; set; }
public string? DeployedCommit { get; set; }
public string? WebhookSecret { get; set; }
public string? SelectedRefundOption { get; set; }
public decimal SpreadPercentage { get; set; }
}
}

View File

@ -1,37 +0,0 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
public class ShopifyRefundWebhook
{
[JsonProperty("order_id")]
public long OrderId { get; set; }
[JsonProperty("refund_line_items")]
public List<ShopifyRefundLineItem> RefundLineItems { get; set; } = new();
[JsonProperty("order_adjustments")]
public List<ShopifyOrderAdjustment> OrderAdjustments { get; set; } = new();
}
public class ShopifyRefundLineItem
{
[JsonProperty("subtotal")]
public decimal Subtotal { get; set; }
}
public class ShopifyOrderAdjustment
{
[JsonProperty("refund_id")]
public long? RefundId { get; set; }
[JsonProperty("amount")]
public decimal Amount { get; set; }
}
public class AccessScopeHandle
{
[JsonProperty("handle")]
public string Handle { get; set; }
}

View File

@ -1,21 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Newtonsoft.Json;
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
public class ShopifyRefundWebhookSettingsViewModel
{
public enum RefundOption
{
CurrentRate,
RateThen
}
public string ShopName { get; set; }
public string WebhookUrl { get; set; }
public string WebhookSecret { get; set; }
[Required(ErrorMessage = "Please select a refund option")]
public RefundOption SelectedRefundOption { get; set; } = RefundOption.CurrentRate;
[Range(0, 99, ErrorMessage = "Spread must be between 0 and 99")]
public decimal SpreadPercentage { get; set; } = 0;
}

View File

@ -26,5 +26,6 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels
public string CLIToken { get; set; }
public string ShopUrl { get; set; }
public string ShopName { get; set; }
public bool RestockOnInvoiceExpired { get; set; }
}
}

View File

@ -1,6 +1,5 @@
@using BTCPayServer.Abstractions.Contracts
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Plugins.ShopifyPlugin
@using Microsoft.AspNetCore.Mvc.TagHelpers
@inject IScopeProvider ScopeProvider
@{
@ -10,15 +9,9 @@
@if (!string.IsNullOrEmpty(storeId))
{
<li class="nav-item">
<a layout-menu-item="Shopify-Settings" asp-controller="UIShopifyV2" asp-action="Settings" asp-route-storeId="@storeId">
<a asp-controller="UIShopifyV2" asp-action="Settings" asp-route-storeId="@storeId" class="nav-link @ViewData.ActivePageClass("Shopify")">
<svg role="img" class="icon icon-plugin" viewBox="0 0 32 32" fill="none"><path transform="scale(.7) translate(5, 5)" d="m20.45 31.97 9.62-2.08-3.5-23.64c-.03-.16-.15-.26-.28-.26l-2.57-.18s-1.7-1.7-1.92-1.88a.41.41 0 0 0-.16-.1l-1.22 28.14zm-4.83-16.9s-1.09-.56-2.37-.56c-1.93 0-2 1.2-2 1.52 0 1.64 4.31 2.29 4.31 6.17 0 3.06-1.92 5.01-4.54 5.01-3.14 0-4.72-1.95-4.72-1.95l.86-2.78s1.66 1.42 3.04 1.42c.9 0 1.3-.72 1.3-1.24 0-2.16-3.54-2.26-3.54-5.81-.04-2.98 2.1-5.9 6.44-5.9 1.68 0 2.5.49 2.5.49l-1.26 3.62zM14.9 1.1c.17 0 .36.06.53.19-1.31.62-2.75 2.18-3.34 5.32-.88.28-1.73.54-2.52.77.69-2.38 2.36-6.26 5.33-6.26zm1.64 3.94v.18l-3.2.98c.63-2.37 1.79-3.53 2.79-3.96.26.67.41 1.57.41 2.8zm.72-2.98c.92.1 1.52 1.15 1.9 2.34-.46.15-.98.3-1.54.49v-.34c0-1-.13-1.82-.36-2.5zm3.99 1.72-.1.03c-.03 0-.39.1-.96.28-.56-1.65-1.56-3.16-3.34-3.16h-.16C16.2.28 15.56 0 15.02 0 10.88 0 8.9 5.17 8.28 7.8c-1.6.48-2.75.84-2.88.9-.9.28-.93.3-1.03 1.15-.1.62-2.44 18.75-2.44 18.75L20.01 32z" fill="currentColor" /></svg>
<span>Shopify v2</span>
</a>
</li>
}
@if (ViewData.IsCategory("ShopifyV2"))
{
<li class="nav-item nav-item-sub">
<a layout-menu-item="Shopify-Refund" asp-controller="UIShopifyV2" asp-action="RefundSettings" asp-route-storeId="@storeId" text-translate="true">Refund Settings</a>
</li>
}

View File

@ -0,0 +1,27 @@
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Plugins.ShopifyPlugin.ViewModels
@using Microsoft.AspNetCore.Routing
@using BTCPayServer.Plugins.ShopifyPlugin.Blazor
@model ShopifySettingsViewModel
@{
ViewData.SetActivePage("Shopify", "Shopify App Configuration", "Shopify");
}
<form method="post" asp-action="Configuration" asp-controller="UIShopifyV2">
<div class="sticky-header d-flex align-items-center justify-content-between">
<h2>@ViewData["Title"]</h2>
<button type="submit" class="btn btn-primary" id="Edit" value="Save Changes">Save Changes</button>
</div>
<partial name="_StatusMessage" />
<div class="row">
<div class="col-xl-10 col-xxl-constrain">
<div class="form-group d-flex align-items-center">
<input asp-for="RestockOnInvoiceExpired" type="checkbox" class="btcpay-toggle me-3" />
<label asp-for="RestockOnInvoiceExpired" class="form-label mb-0 me-1">Restock product items when invoice expires</label>
<span asp-validation-for="RestockOnInvoiceExpired" class="text-danger"></span>
</div>
</div>
</div>
</form>

View File

@ -1,103 +0,0 @@
@using BTCPayServer.Abstractions.Contracts
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.Models
@using BTCPayServer.Plugins.ShopifyPlugin
@using BTCPayServer.Plugins.ShopifyPlugin.ViewModels
@using Microsoft.AspNetCore.Routing
@using BTCPayServer.Plugins.ShopifyPlugin.Blazor
@inject IScopeProvider ScopeProvider
@model ShopifyRefundWebhookSettingsViewModel
@{
var storeId = ScopeProvider.GetCurrentStoreId();
ViewData.SetLayoutModel(new LayoutModel("Shopify-Refund", "Shopify Refund Settings") { ActiveCategory = "ShopifyV2" });
var storeEmailSettingsConfigured = (bool)ViewData["StoreEmailSettingsConfigured"];
Layout = "_Layout";
}
<div class="sticky-header">
<h2 class="my-1">
@ViewData["Title"]
<small>
<a href="https://docs.btcpayserver.org/ShopifyV2/#shopify-refunds" target="_blank" rel="noreferrer noopener"
title="@StringLocalizer["More information..."]">
<vc:icon symbol="info" />
</a>
</small>
</h2>
<div class="d-flex gap-3 mt-3 mt-sm-0">
<button type="submit" form="refundSettingsForm" class="btn btn-primary" @(storeEmailSettingsConfigured ? "" : "disabled")>
Save Settings
</button>
</div>
</div>
<partial name="_StatusMessage" />
<div class="mt-4">
<div class="row">
<div class="col-lg-8">
<div class="alert alert-info">
<p class="mb-2">
This webhook notifies BTCPay Server when a refund occurs for orders paid via BTCPay Server,
and sends a secure claim link to the customer by email.
</p>
<p class="mb-2">
Copy the webhook URL below and create a webhook via
<a href="https://admin.shopify.com/store/@(Model.ShopName)/settings/notifications/webhooks"
target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">
this link</a>.
Use the settings below:
</p>
<ul class="mb-2">
<li>Event: <strong>Refund create</strong></li>
<li>Format: <strong>JSON</strong></li>
<li>Webhook API version: <strong>2026-01</strong></li>
</ul>
</div>
<form id="refundSettingsForm" asp-action="RefundSettings" asp-route-storeId="@storeId" method="post">
<div class="mb-4">
<label class="form-label fw-bold">Webhook URL</label>
<div class="input-group">
<input type="text" class="form-control" value="@Model.WebhookUrl" readonly />
<button type="button" class="btn btn-outline-secondary clipboard-button" data-clipboard="@Model.WebhookUrl">
<vc:icon symbol="actions-copy" />
</button>
</div>
</div>
<div class="mb-4">
<label asp-for="WebhookSecret" class="form-label fw-bold">Webhook Signature</label>
<input asp-for="WebhookSecret" class="form-control" placeholder="Enter your Shopify webhook secret" />
<span asp-validation-for="WebhookSecret" class="text-danger"></span>
<div class="form-text">
Your webhook secret is displayed on the webhook page
</div>
</div>
<div class="mb-4">
<label class="form-label fw-bold">Refund Option Method</label>
<span asp-validation-for="SelectedRefundOption" class="text-danger"></span>
<div class="form-check">
<input class="form-check-input" type="radio" asp-for="SelectedRefundOption" value="CurrentRate" id="refundCurrentRate" />
<label class="form-check-label" for="refundCurrentRate"> Use current exchange rate at time of refund</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" asp-for="SelectedRefundOption" value="RateThen" id="refundRateThen" />
<label class="form-check-label" for="refundRateThen">Use exchange rate at time of original payment</label>
</div>
</div>
<div class="mb-4">
<label asp-for="SpreadPercentage" class="form-label fw-bold">Refund Spread Percentage</label>
<div class="input-group">
<input asp-for="SpreadPercentage" type="number" class="form-control" step="0.01" min="0" max="100" placeholder="e.g. 2.5" />
<span asp-validation-for="SpreadPercentage" class="text-danger"></span>
<span class="input-group-text">%</span>
</div>
<div class="form-text">
Optional. This percentage will be deducted from the refund amount to account for exchange rate volatility.
</div>
</div>
</form>
</div>
</div>
</div>

View File

@ -1,12 +1,10 @@
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.Models
@using BTCPayServer.Plugins.ShopifyPlugin
@using BTCPayServer.Plugins.ShopifyPlugin.ViewModels
@using Microsoft.AspNetCore.Routing
@using BTCPayServer.Plugins.ShopifyPlugin.Blazor
@model ShopifySettingsViewModel
@{
ViewData.SetLayoutModel(new LayoutModel("Shopify-Settings", "Shopify Settings") { ActiveCategory = "ShopifyV2" });
ViewData.SetActivePage("Shopify", "Update Shopify Plugin Settings", "Shopify");
Layout = "_Layout";
bool shopifyCredsSet = Model.Step != ShopifySettingsViewModel.State.WaitingClientCreds;
var storeId = (string)this.Context.GetRouteValue("storeId");
@ -34,6 +32,10 @@
</small>
</h2>
<div>
@if (Model.Step == ShopifySettingsViewModel.State.Done)
{
<a asp-controller="UIShopifyV2" asp-action="Configuration" asp-route-storeId="@storeId" class="btn btn-primary">Configuration</a>
}
@if (Model.Step != ShopifySettingsViewModel.State.WaitingClientCreds)
{
<form method="post">

View File

@ -26,7 +26,7 @@ services:
- "host.docker.internal:host-gateway"
shopify-app-deployer:
image: btcpayserver/shopify-app-deployer:1.6
image: btcpayserver/shopify-app-deployer:1.5
restart: unless-stopped
init: true
expose:

@ -1 +1 @@
Subproject commit aaa244fec37b5d258cd7c2581ba28358b80e3f3e
Subproject commit fcd49ff729943bb04cc4958dae58b47424c0bcdb