Compare commits

...

35 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
nicolas.dorier
34353f60b6
bump shopify-app 2025-04-10 16:11:14 +09:00
nicolas.dorier
1d2e7f7fb3
bump 2025-04-10 16:04:05 +09:00
nicolas.dorier
d96bc87046
Prevent users to deploy a shopify app via HTTP 2025-04-10 16:03:16 +09:00
nicolas.dorier
e2e1229efb
Returns 404 if the shopify order isn't using btcpay 2025-04-09 17:34:26 +09:00
nicolas.dorier
a482636ebc
fix build 2025-04-09 17:07:07 +09:00
nicolas.dorier
caed29b70c
bump 2025-04-09 17:03:40 +09:00
nicolas.dorier
e838f967b5
Send invoice creation error back to client 2025-04-09 17:02:58 +09:00
nicolas.dorier
8ebce46539
bump 2025-03-25 13:18:23 +09:00
Nicolas Dorier
91822f0bbf
Better handling of BTCPay invoice state transitions (#5)
* Better handling of BTCPay invoice state transitions

* Remove dependency on NetSettled
2025-03-25 13:16:47 +09:00
nicolas.dorier
b9d937bf99
bump 2025-03-22 00:06:14 +09:00
nicolas.dorier
b9f26c3d0e
Show error if version is released but not deployed 2025-03-22 00:03:51 +09:00
Chukwuleta Tobechi
c50a8e062e
Auto creates invoice if the payment option is BTCPay Server (#4) 2025-03-21 23:54:30 +09:00
18 changed files with 1008 additions and 265 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.4</Version>
<Version>1.1.2</Version>
</PropertyGroup>
<!-- Plugin development properties -->

View File

@ -55,7 +55,7 @@
});
await using var stream = await resp.Content.ReadAsStreamAsync();
string commit = null;
bool success = false;
bool? success = null;
string version = "";
using var reader = new StreamReader(stream, leaveOpen: true);
while (await reader.ReadLineAsync() is { } line)
@ -68,14 +68,15 @@
commit = line.Substring("COMMIT=".Length).Trim();
if (line.StartsWith("VERSION="))
version = line.Substring("VERSION=".Length).Trim();
if (line.StartsWith("SUCCESS=true"))
if (line.Contains("New version created, but not released.", StringComparison.OrdinalIgnoreCase))
success = false;
if (success is null && line.StartsWith("SUCCESS=true"))
success = true;
logs.AppendLine(line);
StateHasChanged();
}
if (success)
if (success is true)
{
setup.DeployedCommit = commit;
setup.Version = version;
@ -107,11 +108,18 @@
editContext.OnValidationRequested += HandleValidationRequested;
messageStore = new ValidationMessageStore(editContext);
_CanDeploy = true;
var localNetwork = BTCPayServer.Extensions.IsLocalNetwork(new Uri(PluginUrl, UriKind.Absolute).Host);
var pluginUrl = new Uri(PluginUrl, UriKind.Absolute);
var localNetwork = BTCPayServer.Extensions.IsLocalNetwork(pluginUrl.Host);
if (localNetwork)
{
_CanDeploy = false;
FormError = "You can't deploy a shopify app with a local domain. Please use a public domain.";
FormError = "To deploy the Shopify app, you must access your BTCPay Server through a public domain.";
}
else if (pluginUrl.Scheme != Uri.UriSchemeHttps)
{
_CanDeploy = false;
FormError = "To deploy the Shopify app, you must access your BTCPay Server via HTTPS.";
}
try

View File

@ -5,21 +5,15 @@ using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using Google.Apis.Auth.OAuth2;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens;
using System.Net;
using System.Security.Cryptography;
using Newtonsoft.Json.Serialization;
using System.Globalization;
using BTCPayServer.Plugins.ShopifyPlugin.JsonConverters;
using Newtonsoft.Json.Converters;
using System.Text.RegularExpressions;
using System.Security;
using Microsoft.AspNetCore.WebUtilities;
using JArray = Newtonsoft.Json.Linq.JArray;
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
{
@ -79,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,12 +229,24 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
}
const string OrderData =
"""
"""
fragment order on Order {
id
name
cancelledAt
statusPageUrl
customer {
displayName
defaultEmailAddress {
emailAddress
}
}
btcpayInvoiceId: metafield(
namespace: "custom"
key: "btcpay_invoice_id"
) {
value
}
totalOutstandingSet {
shopMoney {
amount
@ -254,8 +260,9 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
transactions @include(if: $includeTxs) {
...orderTransaction
}
...orderPaymentProcess
}
""" + "\n" + TransactionData;
""" + "\n" + TransactionData + "\n" + PaymentProcessData;
private const string TransactionData =
"""
@ -278,9 +285,16 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
}
}
""";
public async Task<ShopifyOrder> GetOrder(long orderId, bool withTransactions = false)
private const string PaymentProcessData =
"""
fragment orderPaymentProcess on Order {
paymentGatewayNames
}
""";
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) {
@ -299,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")
};
@ -406,8 +438,29 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
""";
JObject respObj = await SendGraphQL(req, new JObject() { ["id"] = ShopifyId.DraftOrder(orderId).ToString() });
return ShopifyId.Parse(respObj["data"]["draftOrderDuplicate"]["draftOrder"]["id"].Value<string>());
}
}
}
// https://shopify.dev/docs/api/admin-graphql/latest/mutations/orderUpdate
public async Task<ShopifyId> UpdateOrderMetafields(UpdateMetafields update)
{
var req = """
mutation updateOrderMetafields($input: OrderInput!) {
orderUpdate(input: $input) {
order {
id
}
userErrors {
message
field
}
}
}
""";
JObject respObj = await SendGraphQL(req, new JObject { ["input"] = JObject.FromObject(update, JsonSerializer) });
var d = Unwrap(respObj, "orderUpdate");
return ShopifyId.Parse(d["order"]["id"].Value<string>());
}
}
public record ShopifyApiClientCredentials

View File

@ -49,12 +49,17 @@ 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; }
public ShopifyMoneyBag TotalOutstandingSet { get; set; }
public OrderTransaction[] Transactions { get; set; }
public string[] PaymentGatewayNames { get; set; }
}
public class ShopifyMoneyBag
{
@ -67,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

@ -0,0 +1,14 @@
namespace BTCPayServer.Plugins.ShopifyPlugin.Clients;
public class UpdateMetafields
{
public class Metafield
{
public string Namespace { get; set; }
public string Key { get; set; }
public string Type { get; set; }
public string Value { get; set; }
}
public ShopifyId Id { get; set; }
public Metafield[] Metafields { 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 System.Collections.Generic;
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 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 StoreRepository _storeRepo;
private readonly InvoiceRepository _invoiceRepository;
private readonly UIInvoiceController _invoiceController;
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;
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,83 +260,208 @@ 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)
});
}
}
static AsyncDuplicateLock OrderLocks = new AsyncDuplicateLock();
[AllowAnonymous]
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)]
[HttpGet("~/stores/{storeId}/plugins/shopify-v2/checkout")]
public async Task<IActionResult> Checkout(string storeId, string? checkout_token, CancellationToken cancellationToken)
{
if (checkout_token is null)
return BadRequest("Invalid checkout token");
var client = await this.ShopifyClientFactory.CreateAPIClient(storeId);
if (client is null)
return BadRequest("Shopify plugin isn't configured properly");
var order = await client.GetOrderByCheckoutToken(checkout_token, true);
var store = await _storeRepo.FindStore(storeId);
if (order is null || store is null)
return BadRequest("Invalid checkout token");
public async Task<IActionResult> Checkout(string storeId, string? checkout_token, CancellationToken cancellationToken, bool redirect = true)
{
if (checkout_token is null)
return BadRequest("Invalid checkout token");
var client = await this.ShopifyClientFactory.CreateAPIClient(storeId);
if (client is null)
return BadRequest("Shopify plugin isn't configured properly");
var order = await client.GetOrderByCheckoutToken(checkout_token, true);
var store = await _storeRepo.FindStore(storeId);
if (order is null || store is null)
return BadRequest("Invalid checkout token");
var orderId = order.Id.Id;
var searchTerm = $"{Extensions.SHOPIFY_ORDER_ID_PREFIX}{orderId}";
var invoices = await _invoiceRepository.GetInvoices(new InvoiceQuery()
var containsKeyword = order.PaymentGatewayNames.Any(pgName => ShopifyHostedService.IsBTCPayServerGateway(pgName));
if (!containsKeyword)
return NotFound("Order wasn't fulfilled with BTCPay Server payment option");
var orderId = order.Id.Id;
var searchTerm = $"{Extensions.SHOPIFY_ORDER_ID_PREFIX}{orderId}";
var invoices = await _invoiceRepository.GetInvoices(new InvoiceQuery()
{
TextSearch = searchTerm,
StoreId = new[] { storeId }
});
// This prevent a race condition where two invoices get created for same order
using var l = await OrderLocks.LockAsync(orderId, cancellationToken);
var orderInvoices =
invoices.Where(e => e.GetShopifyOrderId() == orderId).ToArray();
var currentInvoice = orderInvoices.FirstOrDefault();
if (currentInvoice != null)
return redirect ? RedirectToInvoiceCheckout(currentInvoice.Id) : Ok();
var baseTx = order.Transactions.FirstOrDefault(t => t is { Kind: "SALE", ManuallyCapturable: true });
if (baseTx is null)
return BadRequest("The shopify order is not capturable");
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName);
var amount = order.TotalOutstandingSet.PresentmentMoney;
InvoiceEntity invoice;
try
{
invoice = await _invoiceController.CreateInvoiceCoreRaw(
new CreateInvoiceRequest()
{
Amount = amount.Amount,
Currency = amount.CurrencyCode,
Metadata = new JObject
{
["orderId"] = order.Name,
["orderUrl"] = GetOrderUrl(settings?.Setup?.ShopUrl, orderId),
["shopifyOrderId"] = orderId,
["shopifyOrderName"] = order.Name,
["gateway"] = baseTx.Gateway
},
AdditionalSearchTerms =
[
order.Name,
orderId.ToString(CultureInfo.InvariantCulture),
searchTerm
],
Checkout = new()
{
RedirectURL = order.StatusPageUrl
}
}, store,
Request.GetAbsoluteRoot(), [searchTerm], cancellationToken);
}
catch (BitpayHttpException e)
{
return BadRequest(e.Message);
}
await client.UpdateOrderMetafields(new()
{
TextSearch = searchTerm,
StoreId = new[] { storeId }
});
// This prevent a race condition where two invoices get created for same order
using var l = await OrderLocks.LockAsync(orderId, cancellationToken);
var orderInvoices =
invoices.Where(e => e.GetShopifyOrderId() == orderId).ToArray();
var currentInvoice = orderInvoices.FirstOrDefault();
if (currentInvoice != null)
return RedirectToInvoiceCheckout(currentInvoice.Id);
var baseTx = order.Transactions.FirstOrDefault(t => t is { Kind: "SALE", ManuallyCapturable: true });
if (baseTx is null)
return BadRequest("The shopify order is not capturable");
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName);
var amount = order.TotalOutstandingSet.PresentmentMoney;
var invoice = await _invoiceController.CreateInvoiceCoreRaw(
new CreateInvoiceRequest()
Id = ShopifyId.Order(orderId),
Metafields = [
new()
{
Amount = amount.Amount,
Currency = amount.CurrencyCode,
Metadata = new JObject
{
["orderId"] = order.Name,
["orderUrl"] = GetOrderUrl(settings?.Setup?.ShopUrl, orderId),
["shopifyOrderId"] = orderId,
["shopifyOrderName"] = order.Name,
["gateway"] = baseTx.Gateway
},
AdditionalSearchTerms =
[
order.Name,
orderId.ToString(CultureInfo.InvariantCulture),
searchTerm
],
Checkout = new()
{
RedirectURL = order.StatusPageUrl
}
}, store,
Request.GetAbsoluteRoot(), [searchTerm], cancellationToken);
return RedirectToInvoiceCheckout(invoice.Id);
}
Namespace = "custom",
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();
}
private string? GetOrderUrl(string? shopUrl, long shopifyOrderId)
private string? GetOrderUrl(string? shopUrl, long shopifyOrderId)
{
var shopName = GetShopName(shopUrl);
if (shopName is null)
@ -324,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,10 +1,9 @@
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services;
using BTCPayServer.Plugins.Emails;
using BTCPayServer.Plugins.Emails.Views;
using BTCPayServer.Plugins.ShopifyPlugin.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using System;
namespace BTCPayServer.Plugins.ShopifyPlugin;
@ -12,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,11 +13,6 @@ using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace BTCPayServer.Plugins.ShopifyPlugin.Services;
@ -72,104 +72,114 @@ public class ShopifyHostedService : EventHostedServiceBase
}
}
async Task<InvoiceLogs> Process(long shopifyOrderId, InvoiceEntity invoice)
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));
}
async Task<InvoiceLogs> Process(long shopifyOrderId, InvoiceEntity invoice)
{
var logs = new InvoiceLogs();
var client = await shopifyClientFactory.CreateAPIClient(invoice.StoreId);
if (client is null)
return logs;
if (await client.GetOrder(shopifyOrderId, true) is not { } order)
return logs;
var saleTx = order.Transactions.FirstOrDefault(h => h is { Kind: "SALE", Status: "PENDING" });
if (saleTx is null)
return logs;
//technically, this exploit should not be possible as we use internal invoice tags to verify that the invoice was created by our controlled, dedicated endpoint.
if (!invoice.Currency.Equals(saleTx.AmountSet.PresentmentMoney.CurrencyCode, StringComparison.OrdinalIgnoreCase))
{
// because of parent_id present, currency will always be the one from parent transaction
// malicious attacker could potentially exploit this by creating invoice
// in different currency and paying that one, registering order on Shopify as paid
// so if currency is supplied and is different from parent transaction currency we just won't register
logs.Write("Currency mismatch on Shopify.", InvoiceEventData.EventSeverity.Error);
return logs;
}
//of the successful ones, get the ones we registered as a valid payment
var captures =
order.Transactions
.Where(h => h is { Kind: "SALE", Status: "SUCCESS" }).ToArray();
//of the successful ones, get the ones we registered as a voiding of a previous successful payment
var refunds =
order.Transactions
.Where(h => h is { Kind: "REFUND", Status: "SUCCESS" }).ToArray();
var saleTx = order.Transactions
.Where(h => h is { Kind: "SALE", Status: "PENDING" })
.Where(h => h.AmountSet.PresentmentMoney.CurrencyCode.Equals(invoice.Currency, StringComparison.OrdinalIgnoreCase))
.FirstOrDefault();
if (saleTx is null)
return logs;
bool canRefund = captures.Length > 0 && captures.Length > refunds.Length;
if (invoice is { Status: InvoiceStatus.Settled } or { Status: InvoiceStatus.Expired, ExceptionStatus: InvoiceExceptionStatus.PaidPartial })
{
if (canRefund)
{
if (order.CancelledAt is not null)
{
logs.Write("The shopify order has already been cancelled, but the BTCPay Server has been successfully paid.",
InvoiceEventData.EventSeverity.Warning);
}
else
{
logs.Write("A transaction was previously recorded against the Shopify order. Skipping.",
InvoiceEventData.EventSeverity.Warning);
}
return logs;
}
var shopifyPaid =
order.Transactions
.Where(h => h is { Kind: "SALE", Status: "SUCCESS" })
.Select(h => h.AmountSet.PresentmentMoney.Amount)
.Sum();
if (saleTx.ManuallyCapturable)
{
try
{
decimal amount = invoice.Status == InvoiceStatus.Settled ? invoice.Price
: Math.Round(invoice.PaidAmount.Net, _currencyNameTable.GetNumberFormatInfo(invoice.Currency)?.CurrencyDecimalDigits ?? 2);
decimal? btcpayPaid = invoice switch
{
{ Status: InvoiceStatus.Settled } => invoice.Price,
{ Status: InvoiceStatus.Expired, ExceptionStatus: InvoiceExceptionStatus.PaidPartial } => NetSettled(invoice),
{ Status: InvoiceStatus.Invalid, ExceptionStatus: InvoiceExceptionStatus.Marked } => 0.0m,
{ Status: InvoiceStatus.Invalid } => NetSettled(invoice),
_ => null
};
if (btcpayPaid is not null)
{
var capture = btcpayPaid.Value - shopifyPaid;
if (capture > 0m)
{
if (order.CancelledAt is not null)
{
logs.Write("The shopify order has already been cancelled, but the BTCPay Server has been successfully paid.",
InvoiceEventData.EventSeverity.Warning);
return logs;
}
await client.CaptureOrder(new()
{
Currency = invoice.Currency,
Amount = amount,
Id = order.Id,
ParentTransactionId = saleTx.Id
});
logs.Write(
$"Successfully captured the order on Shopify.",
InvoiceEventData.EventSeverity.Info);
}
catch (Exception e)
{
logs.Write($"Failed to capture the Shopify order. {e.Message}",
InvoiceEventData.EventSeverity.Error);
}
}
}
else if(order.CancelledAt is null)
{
try
{
await client.CancelOrder(new()
{
OrderId = order.Id,
NotifyCustomer = false,
Reason = OrderCancelReason.DECLINED,
Restock = true,
Refund = canRefund,
StaffNote = $"BTCPay Invoice {invoice.Id} is {invoice.Status}"
});
logs.Write($"Shopify order cancelled. (Invoice Status: {invoice.Status})", InvoiceEventData.EventSeverity.Warning);
}
catch (Exception e)
{
logs.Write($"Failed to cancel the Shopify order. {e.Message}",
InvoiceEventData.EventSeverity.Error);
}
}
return logs;
}
if (saleTx.ManuallyCapturable)
{
try
{
await client.CaptureOrder(new()
{
Currency = invoice.Currency,
Amount = capture,
Id = order.Id,
ParentTransactionId = saleTx.Id
});
logs.Write(
$"Successfully captured the order on Shopify. ({capture} {invoice.Currency})",
InvoiceEventData.EventSeverity.Info);
}
catch (Exception e)
{
logs.Write($"Failed to capture the Shopify order. ({capture} {invoice.Currency}) {e.Message} ",
InvoiceEventData.EventSeverity.Error);
}
}
}
}
else if (order.CancelledAt is null)
{
try
{
await client.CancelOrder(new()
{
OrderId = order.Id,
NotifyCustomer = false,
Reason = OrderCancelReason.DECLINED,
Restock = true,
Refund = false,
StaffNote = $"BTCPay Invoice {invoice.Id} is {invoice.Status}"
});
logs.Write($"Shopify order cancelled. (Invoice Status: {invoice.Status})", InvoiceEventData.EventSeverity.Warning);
}
catch (Exception e)
{
logs.Write($"Failed to cancel the Shopify order. {e.Message}",
InvoiceEventData.EventSeverity.Error);
}
}
return logs;
}
private decimal NetSettled(InvoiceEntity invoice)
{
decimal netSettled = netSettled = invoice.GetPayments(true)
.Where(payment => payment.Status == PaymentStatus.Settled)
.Sum(payment => payment.InvoicePaidAmount.Net);
// Later we can just use this instead of calculating ourselves
// decimal netSettled = invoice.NetSettled;
return Math.Round(netSettled, _currencyNameTable.GetNumberFormatInfo(invoice.Currency)?.CurrencyDecimalDigits ?? 2);
}
}

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");
@ -147,6 +149,7 @@
<ul>
<li><a href="https://docs.btcpayserver.org/ShopifyV2/#customize-the-thank-you-page" rel="noreferrer noopener" target="_blank">Customize the checkout's "Thank You" page</a> on your store's <a href="https://admin.shopify.com/store/@(Model.ShopName)/settings/checkout" rel="noreferrer noopener" target="_blank">checkout settings page</a>.</li>
<li><a href="https://docs.btcpayserver.org/ShopifyV2/#set-up-a-custom-payment-method-in-shopify" rel="noreferrer noopener" target="_blank">Add BTCPay Server as a manual payment method</a> on your store's <a href="https://admin.shopify.com/store/@(Model.ShopName)/settings/payments" rel="noreferrer noopener" target="_blank">payment settings page</a>.</li>
<li>Grant network access to your application. Go to your app on your partner's dashboard > API Access > scroll down to 'Allow network access in checkout and account UI extensions' and grant network access.</li>
</ul>
}
else

View File

@ -16,8 +16,9 @@
{
<p class="lead text-secondary mt-3">Your BTCPay Server-Shopify installation was successful!</p>
<p class="mt-3">
<span>To start accepting payments, make sure to complete the two following steps:</span>
<span>To start accepting payments, make sure to complete the three following steps:</span>
<ul class="list-unstyled">
<li>Grant network access to your application. Go to your app on your partner's dashboard > API Access > scroll down to 'Allow network access in checkout and account UI extensions' and grant network access.</li>
<li><a href="https://docs.btcpayserver.org/ShopifyV2/#customize-the-thank-you-page" rel="noreferrer noopener" target="_blank">Customize the checkout's "Thank You" page</a> on <a href="https://admin.shopify.com/store/@(Model.ShopName)/settings/checkout" rel="noreferrer noopener" target="_blank">this page</a>.</li>
<li><a href="https://docs.btcpayserver.org/ShopifyV2/#set-up-a-custom-payment-method-in-shopify" rel="noreferrer noopener" target="_blank">Add BTCPay Server as a Manual payment method</a> on <a href="https://admin.shopify.com/store/@(Model.ShopName)/settings/payments" rel="noreferrer noopener" target="_blank">this page</a>.</li>
</ul>

View File

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

@ -1 +1 @@
Subproject commit 1c8ded93629e5764004faea269c1ebce0a4a1dd1
Subproject commit aaa244fec37b5d258cd7c2581ba28358b80e3f3e