Compare commits

...

23 Commits

Author SHA1 Message Date
Chukwuleta Tobechi
e9f9a1d401
Merge pull request #11 from btcpayserver/ft/refund_options
Make current exchange rate the default refund option
2026-01-22 18:41:01 +01:00
Chukwuleta Tobechi
3c1e81cf0b update the refund image 2026-01-22 18:35:18 +01:00
Chukwuleta Tobechi
7a510d3358 bump plugin for refund option default 2026-01-22 10:05:35 +01:00
Chukwuleta Tobechi
bcd761ac23 make current exchange rate default 2026-01-22 09:54:39 +01:00
Chukwuleta Tobechi
7d0434570e add doc links to refund header 2026-01-21 16:41:24 +01:00
Chukwuleta Tobechi
c5ad9c60d8 fix settings view header name 2026-01-21 15:22:25 +01:00
Chukwuleta Tobechi
0045758824
Merge pull request #10 from btcpayserver/ft/check_access_scope
ensure required scopes are available for refunds
2026-01-21 13:57:49 +01:00
Chukwuleta Tobechi
672840a6e6 bump version 2026-01-21 11:19:08 +01:00
Chukwuleta Tobechi
68a8281626 ensure required scopes are available for refunds 2026-01-21 11:17:42 +01:00
Chukwuleta Tobechi
5e4a73887b bump plugin version 2026-01-21 09:11:35 +01:00
Chukwuleta Tobechi
ce17d5bd78
Merge pull request #9 from btcpayserver/ft/order_reversal
Shopify order refund flow
2026-01-18 15:05:49 +01:00
Chukwuleta Tobechi
4449f096dc use refund type 2026-01-17 20:44:31 +01:00
Chukwuleta Tobechi
476ce1731f Validate final amount is positive after spread application 2026-01-16 16:32:39 +01:00
Chukwuleta Tobechi
d3c7721ad8 test smtp sender 2026-01-16 15:51:05 +01:00
Chukwuleta Tobechi
46764256e3 Include refund_line_items in the refund amount calculation 2026-01-16 13:44:05 +01:00
Chukwuleta Tobechi
11330348a9 include shopify refund to email rule 2026-01-16 13:10:18 +01:00
Chukwuleta Tobechi
5042e7a116 bump btcpay submodules. Handle header nav 2026-01-16 11:53:32 +01:00
Nicolas Dorier
98ec407efb
Add coderabbit conf 2026-01-16 08:58:26 +09:00
Chukwuleta Tobechi
35fb298d0c bump docker image for app in plugin 2026-01-15 14:48:43 +01:00
Chukwuleta Tobechi
774a564325 shopify order refund flow 2026-01-15 14:40:58 +01:00
Chukwuleta Tobechi
0abb048cff refund settings view 2026-01-14 19:20:58 +01:00
Chukwuleta Tobechi
653618245e order refund flow 2026-01-14 16:14:55 +01:00
Chukwuleta Tobechi
2d711f55e8
Update shopify-app-deployer image version to 1.5 (#7) 2025-11-13 22:41:46 +09:00
15 changed files with 762 additions and 96 deletions

139
.coderabbit.yaml Normal file
View File

@ -0,0 +1,139 @@
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.0.8</Version>
<Version>1.1.2</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 JArray = Newtonsoft.Json.Linq.JArray;
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
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)
public bool VerifyWebhookSignature(string body, string hmac, string webhookSecret)
{
var keyBytes = Encoding.UTF8.GetBytes(_credentials.ClientSecret);
var keyBytes = Encoding.UTF8.GetBytes(webhookSecret);
using (var hmacObj = new HMACSHA256(keyBytes))
{
var hashBytes = hmacObj.ComputeHash(Encoding.UTF8.GetBytes(body));
@ -235,6 +235,18 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
name
cancelledAt
statusPageUrl
customer {
displayName
defaultEmailAddress {
emailAddress
}
}
btcpayInvoiceId: metafield(
namespace: "custom"
key: "btcpay_invoice_id"
) {
value
}
totalOutstandingSet {
shopMoney {
amount
@ -282,7 +294,7 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
""";
public async Task<ShopifyOrder> GetOrder(long orderId, bool withTransactions = false)
{
// https://shopify.dev/docs/api/admin-graphql/2024-10/queries/order
// https://shopify.dev/docs/api/admin-graphql/2026-01/queries/order
var req = """
query getOrderDetails($orderId: ID!, $includeTxs: Boolean!) {
order(id: $orderId) {
@ -301,12 +313,30 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
return d?.ToObject<ShopifyOrder>(JsonSerializer);
}
private HttpRequestMessage CreateGraphQLRequest(string req, JObject variables = null)
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)
{
var jobj = new JObject() { ["query"] = req };
if (variables is not null)
jobj.Add("variables", variables);
return new HttpRequestMessage(HttpMethod.Post, $"{_shopUrl}/admin/api/2024-10/graphql.json")
return new HttpRequestMessage(HttpMethod.Post, $"{_shopUrl}/admin/api/2026-01/graphql.json")
{
Content = new StringContent(jobj.ToString(), Encoding.UTF8, "application/json")
};
@ -430,7 +460,6 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
var d = Unwrap(respObj, "orderUpdate");
return ShopifyId.Parse(d["order"]["id"].Value<string>());
}
}

View File

@ -49,7 +49,11 @@ public class OrderTransaction
}
public class ShopifyOrder
{
public string StatusPageUrl { get; set; }
public ShopifyCustomer Customer { get; set; }
[JsonProperty("btcpayInvoiceId")]
public ShopifyMetafield BtcpayInvoiceId { get; set; }
public string StatusPageUrl { get; set; }
public ShopifyId Id { get; set; }
public string Name { get; set; }
public DateTimeOffset? CancelledAt { get; set; }
@ -68,3 +72,16 @@ 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,33 +1,45 @@
#nullable enable
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
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.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 Microsoft.Extensions.Configuration;
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 Newtonsoft.Json.Linq;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Plugins.ShopifyPlugin;
@ -35,23 +47,46 @@ namespace BTCPayServer.Plugins.ShopifyPlugin;
[AutoValidateAntiforgeryToken]
public class UIShopifyV2Controller : Controller
{
private readonly RateFetcher _rateProvider;
private readonly StoreRepository _storeRepo;
private readonly InvoiceRepository _invoiceRepository;
private readonly UIInvoiceController _invoiceController;
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;
public UIShopifyV2Controller
(
ShopifyClientFactory shopifyClientFactory,
StoreRepository storeRepo,
UIInvoiceController invoiceController,
IConfiguration configuration,
InvoiceRepository invoiceRepository)
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)
{
_storeRepo = storeRepo;
ShopifyClientFactory = shopifyClientFactory;
_invoiceRepository = invoiceRepository;
_rateProvider = rateProvider;
_defaultRules = defaultRules;
_payoutHandlers = payoutHandlers;
_eventAggregator = eventAggregator;
_dbContextFactory = dbContextFactory;
_currencyNameTable = currencyNameTable;
_invoiceRepository = invoiceRepository;
_invoiceController = invoiceController;
}
_emailSenderFactory = emailSenderFactory;
ShopifyClientFactory = shopifyClientFactory;
_paymentHostedService = paymentHostedService;
}
public StoreData CurrentStore => HttpContext.GetStoreData();
public ShopifyClientFactory ShopifyClientFactory { get; }
@ -214,8 +249,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,
@ -225,17 +260,108 @@ public class UIShopifyV2Controller : Controller
AppDeployed = settings.Setup is { DeployedCommit: {} },
AppInstalled = settings.Setup is { AccessToken: {} },
AppName = settings.PreferredAppName ?? ShopifyStoreSettings.DefaultAppName,
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
}
});
Step = ShopifySetupStep(settings)
});
}
}
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)
{
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName) ?? new();
settings.Setup ??= new();
var setupState = ShopifySetupStep(settings);
if (setupState != ShopifySettingsViewModel.State.Done)
{
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",
Severity = StatusMessageModel.StatusSeverity.Error
});
return RedirectToAction(nameof(Settings), 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
{
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);
}
[HttpPost("~/stores/{storeId}/plugins/shopify-v2/refunds/settings")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RefundSettings(string storeId, ShopifyRefundWebhookSettingsViewModel vm)
{
if (!ModelState.IsValid)
{
vm.WebhookUrl = Url.Action(nameof(Webhook), "UIShopifyV2", new { storeId }, Request.Scheme);
return View("/Views/UIShopify/RefundSettings.cshtml", vm);
}
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);
this.TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Refund settings saved successfully",
Severity = StatusMessageModel.StatusSeverity.Success
});
return RedirectToAction(nameof(RefundSettings), new { storeId });
}
static AsyncDuplicateLock OrderLocks = new AsyncDuplicateLock();
[AllowAnonymous]
[EnableCors(CorsPolicies.All)]
@ -322,8 +448,15 @@ 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();
}
@ -351,43 +484,176 @@ 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 &&
GetHeader("X-Shopify-Sub-Topic") is string subtopic)
return new WebhookInfo(hmac, $"{topic}/{subtopic}");
if (GetHeader("X-Shopify-Hmac-SHA256") is string hmac && GetHeader("X-Shopify-Topic") is string topic)
return new WebhookInfo(hmac, topic);
return null;
}
[AllowAnonymous]
static readonly AsyncDuplicateLock RefundLocks = new AsyncDuplicateLock();
[AllowAnonymous]
[IgnoreAntiforgeryToken]
[HttpPost("~/stores/{storeId}/plugins/shopify-v2/webhooks")]
// We actually do not use it, but shopify requires to still listen to it...
// Handles refunds, plus all forms of webhook... plus 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");
var client = await this.ShopifyClientFactory.CreateAppClient(storeId);
if (client is null)
return NotFound();
if (!client.VerifyWebhookSignature(requestBody, webhookInfo.HMac))
return Unauthorized("Invalid HMAC signature");
if (string.IsNullOrEmpty(settings?.Setup?.WebhookSecret))
return BadRequest("Webhook secret not saved yet");
// 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);
//}
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");
return Ok();
}
// 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();
}
}

View File

@ -1,5 +1,7 @@
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;
@ -9,13 +11,47 @@ public class Plugin : BaseBTCPayServerPlugin
{
public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } =
{
new IBTCPayServerPlugin.PluginDependency { Identifier = nameof(BTCPayServer), Condition = ">=2.0.0" }
new IBTCPayServerPlugin.PluginDependency { Identifier = nameof(BTCPayServer), Condition = ">=2.3.3" }
};
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,4 +1,9 @@
using BTCPayServer.Client.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
@ -8,10 +13,6 @@ using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace BTCPayServer.Plugins.ShopifyPlugin.Services;
@ -71,8 +72,13 @@ public class ShopifyHostedService : EventHostedServiceBase
}
}
private static string[] _keywords = new[] { "bitcoin", "btc", "btcpayserver", "btcpay server" };
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));

View File

@ -24,5 +24,8 @@ 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

@ -0,0 +1,37 @@
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

@ -0,0 +1,21 @@
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

@ -1,5 +1,6 @@
@using BTCPayServer.Abstractions.Contracts
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Plugins.ShopifyPlugin
@using Microsoft.AspNetCore.Mvc.TagHelpers
@inject IScopeProvider ScopeProvider
@{
@ -9,9 +10,15 @@
@if (!string.IsNullOrEmpty(storeId))
{
<li class="nav-item">
<a asp-controller="UIShopifyV2" asp-action="Settings" asp-route-storeId="@storeId" class="nav-link @ViewData.ActivePageClass("Shopify")">
<a layout-menu-item="Shopify-Settings" asp-controller="UIShopifyV2" asp-action="Settings" asp-route-storeId="@storeId">
<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,103 @@
@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,10 +1,12 @@
@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.SetActivePage("Shopify", "Update Shopify Plugin Settings", "Shopify");
ViewData.SetLayoutModel(new LayoutModel("Shopify-Settings", "Shopify Settings") { ActiveCategory = "ShopifyV2" });
Layout = "_Layout";
bool shopifyCredsSet = Model.Step != ShopifySettingsViewModel.State.WaitingClientCreds;
var storeId = (string)this.Context.GetRouteValue("storeId");

View File

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

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