Compare commits
1 Commits
master
...
ft/shopify
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c3abaf1e1 |
139
.coderabbit.yaml
139
.coderabbit.yaml
@ -1,139 +0,0 @@
|
||||
language: en-US
|
||||
tone_instructions: ''
|
||||
early_access: false
|
||||
enable_free_tier: true
|
||||
reviews:
|
||||
summaries:
|
||||
include_walkthrough: false
|
||||
profile: chill
|
||||
request_changes_workflow: false
|
||||
high_level_summary: false
|
||||
high_level_summary_placeholder: '@coderabbitai summary'
|
||||
high_level_summary_in_walkthrough: false
|
||||
auto_title_placeholder: '@coderabbitai'
|
||||
auto_title_instructions: ''
|
||||
review_status: true
|
||||
commit_status: true
|
||||
fail_commit_status: false
|
||||
collapse_walkthrough: false
|
||||
changed_files_summary: false
|
||||
sequence_diagrams: false
|
||||
assess_linked_issues: true
|
||||
related_issues: true
|
||||
related_prs: true
|
||||
suggested_labels: true
|
||||
auto_apply_labels: false
|
||||
suggested_reviewers: true
|
||||
auto_assign_reviewers: false
|
||||
poem: false
|
||||
labeling_instructions: []
|
||||
path_filters: []
|
||||
path_instructions: []
|
||||
abort_on_close: true
|
||||
pr_description: false
|
||||
auto_review:
|
||||
enabled: true
|
||||
auto_incremental_review: true
|
||||
ignore_title_keywords: []
|
||||
labels: []
|
||||
drafts: false
|
||||
base_branches: []
|
||||
finishing_touches:
|
||||
docstrings:
|
||||
enabled: false
|
||||
tools:
|
||||
ast-grep:
|
||||
rule_dirs: []
|
||||
util_dirs: []
|
||||
essential_rules: true
|
||||
packages: []
|
||||
shellcheck:
|
||||
enabled: true
|
||||
ruff:
|
||||
enabled: true
|
||||
markdownlint:
|
||||
enabled: true
|
||||
github-checks:
|
||||
enabled: true
|
||||
timeout_ms: 90000
|
||||
languagetool:
|
||||
enabled: true
|
||||
enabled_rules: []
|
||||
disabled_rules: []
|
||||
enabled_categories: []
|
||||
disabled_categories: []
|
||||
enabled_only: false
|
||||
level: default
|
||||
biome:
|
||||
enabled: true
|
||||
hadolint:
|
||||
enabled: true
|
||||
swiftlint:
|
||||
enabled: true
|
||||
phpstan:
|
||||
enabled: true
|
||||
level: default
|
||||
golangci-lint:
|
||||
enabled: true
|
||||
yamllint:
|
||||
enabled: true
|
||||
gitleaks:
|
||||
enabled: true
|
||||
checkov:
|
||||
enabled: true
|
||||
detekt:
|
||||
enabled: true
|
||||
eslint:
|
||||
enabled: true
|
||||
rubocop:
|
||||
enabled: true
|
||||
buf:
|
||||
enabled: true
|
||||
regal:
|
||||
enabled: true
|
||||
actionlint:
|
||||
enabled: true
|
||||
pmd:
|
||||
enabled: true
|
||||
cppcheck:
|
||||
enabled: true
|
||||
semgrep:
|
||||
enabled: true
|
||||
circleci:
|
||||
enabled: true
|
||||
sqlfluff:
|
||||
enabled: true
|
||||
prismaLint:
|
||||
enabled: true
|
||||
oxc:
|
||||
enabled: true
|
||||
shopifyThemeCheck:
|
||||
enabled: true
|
||||
chat:
|
||||
auto_reply: true
|
||||
create_issues: true
|
||||
integrations:
|
||||
jira:
|
||||
usage: auto
|
||||
linear:
|
||||
usage: auto
|
||||
knowledge_base:
|
||||
opt_out: false
|
||||
web_search:
|
||||
enabled: true
|
||||
learnings:
|
||||
scope: auto
|
||||
issues:
|
||||
scope: auto
|
||||
jira:
|
||||
usage: auto
|
||||
project_keys: []
|
||||
linear:
|
||||
usage: auto
|
||||
team_keys: []
|
||||
pull_requests:
|
||||
scope: auto
|
||||
code_generation:
|
||||
docstrings:
|
||||
language: en-US
|
||||
path_instructions: []
|
||||
@ -7,7 +7,7 @@
|
||||
<PropertyGroup>
|
||||
<Product>BTCPay Server Shopify Plugin v2</Product>
|
||||
<Description>BTCPay server integration with Shopify.</Description>
|
||||
<Version>1.1.2</Version>
|
||||
<Version>1.0.8</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Plugin development properties -->
|
||||
|
||||
@ -13,7 +13,7 @@ using Newtonsoft.Json.Serialization;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.JsonConverters;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
|
||||
using JArray = Newtonsoft.Json.Linq.JArray;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
|
||||
{
|
||||
@ -73,9 +73,9 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
|
||||
return JsonConvert.DeserializeObject<AccessTokenResponse>(strResp);
|
||||
}
|
||||
|
||||
public bool VerifyWebhookSignature(string body, string hmac, string webhookSecret)
|
||||
public bool VerifyWebhookSignature(string body, string hmac)
|
||||
{
|
||||
var keyBytes = Encoding.UTF8.GetBytes(webhookSecret);
|
||||
var keyBytes = Encoding.UTF8.GetBytes(_credentials.ClientSecret);
|
||||
using (var hmacObj = new HMACSHA256(keyBytes))
|
||||
{
|
||||
var hashBytes = hmacObj.ComputeHash(Encoding.UTF8.GetBytes(body));
|
||||
@ -235,18 +235,6 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
|
||||
name
|
||||
cancelledAt
|
||||
statusPageUrl
|
||||
customer {
|
||||
displayName
|
||||
defaultEmailAddress {
|
||||
emailAddress
|
||||
}
|
||||
}
|
||||
btcpayInvoiceId: metafield(
|
||||
namespace: "custom"
|
||||
key: "btcpay_invoice_id"
|
||||
) {
|
||||
value
|
||||
}
|
||||
totalOutstandingSet {
|
||||
shopMoney {
|
||||
amount
|
||||
@ -294,7 +282,7 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
|
||||
""";
|
||||
public async Task<ShopifyOrder> GetOrder(long orderId, bool withTransactions = false)
|
||||
{
|
||||
// https://shopify.dev/docs/api/admin-graphql/2026-01/queries/order
|
||||
// https://shopify.dev/docs/api/admin-graphql/2024-10/queries/order
|
||||
var req = """
|
||||
query getOrderDetails($orderId: ID!, $includeTxs: Boolean!) {
|
||||
order(id: $orderId) {
|
||||
@ -313,30 +301,12 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
|
||||
return d?.ToObject<ShopifyOrder>(JsonSerializer);
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetGrantedAccessScopes()
|
||||
{
|
||||
// https://shopify.dev/docs/api/admin-graphql/latest/queries/currentAppInstallation
|
||||
var req = """
|
||||
query GetAccessScopes {
|
||||
currentAppInstallation {
|
||||
accessScopes {
|
||||
handle
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
var resp = await SendGraphQL(req, new JObject());
|
||||
var d = Unwrap(resp, "currentAppInstallation");
|
||||
var accessScopes = d?["accessScopes"]?.ToObject<List<AccessScopeHandle>>(JsonSerializer);
|
||||
return accessScopes?.Select(s => s.Handle).ToList() ?? new List<string>();
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateGraphQLRequest(string req, JObject variables = null)
|
||||
private HttpRequestMessage CreateGraphQLRequest(string req, JObject variables = null)
|
||||
{
|
||||
var jobj = new JObject() { ["query"] = req };
|
||||
if (variables is not null)
|
||||
jobj.Add("variables", variables);
|
||||
return new HttpRequestMessage(HttpMethod.Post, $"{_shopUrl}/admin/api/2026-01/graphql.json")
|
||||
return new HttpRequestMessage(HttpMethod.Post, $"{_shopUrl}/admin/api/2024-10/graphql.json")
|
||||
{
|
||||
Content = new StringContent(jobj.ToString(), Encoding.UTF8, "application/json")
|
||||
};
|
||||
@ -460,6 +430,7 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
|
||||
var d = Unwrap(respObj, "orderUpdate");
|
||||
return ShopifyId.Parse(d["order"]["id"].Value<string>());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -49,11 +49,7 @@ public class OrderTransaction
|
||||
}
|
||||
public class ShopifyOrder
|
||||
{
|
||||
public ShopifyCustomer Customer { get; set; }
|
||||
|
||||
[JsonProperty("btcpayInvoiceId")]
|
||||
public ShopifyMetafield BtcpayInvoiceId { get; set; }
|
||||
public string StatusPageUrl { get; set; }
|
||||
public string StatusPageUrl { get; set; }
|
||||
public ShopifyId Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public DateTimeOffset? CancelledAt { get; set; }
|
||||
@ -72,16 +68,3 @@ public class ShopifyMoney
|
||||
[JsonConverter(typeof(BTCPayServer.JsonConverters.NumericStringJsonConverter))]
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
public class ShopifyCustomer
|
||||
{
|
||||
public string DisplayName { get; set; }
|
||||
public ShopifyCustomerEmail DefaultEmailAddress { get; set; }
|
||||
}
|
||||
public class ShopifyCustomerEmail
|
||||
{
|
||||
public string EmailAddress { get; set; }
|
||||
}
|
||||
public class ShopifyMetafield
|
||||
{
|
||||
public string Value { get; set; }
|
||||
}
|
||||
@ -1,45 +1,33 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Lightning.LndHub;
|
||||
using BTCPayServer.Payouts;
|
||||
using BTCPayServer.Plugins.Emails;
|
||||
using BTCPayServer.Plugins.Emails.Controllers;
|
||||
using BTCPayServer.Plugins.Emails.HostedServices;
|
||||
using BTCPayServer.Plugins.Emails.Services;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.Clients;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.Services;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
|
||||
using BTCPayServer.Rating;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.Services;
|
||||
using BTCPayServer.Client.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using StoreData = BTCPayServer.Data.StoreData;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Globalization;
|
||||
using BTCPayServer.Lightning.LndHub;
|
||||
using System.Threading;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.Clients;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin;
|
||||
|
||||
@ -47,46 +35,23 @@ namespace BTCPayServer.Plugins.ShopifyPlugin;
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public class UIShopifyV2Controller : Controller
|
||||
{
|
||||
private readonly RateFetcher _rateProvider;
|
||||
private readonly StoreRepository _storeRepo;
|
||||
private readonly EventAggregator _eventAggregator;
|
||||
private readonly CurrencyNameTable _currencyNameTable;
|
||||
private readonly DefaultRulesCollection _defaultRules;
|
||||
private readonly InvoiceRepository _invoiceRepository;
|
||||
private readonly EmailSenderFactory _emailSenderFactory;
|
||||
private readonly UIInvoiceController _invoiceController;
|
||||
private readonly PayoutMethodHandlerDictionary _payoutHandlers;
|
||||
private readonly ApplicationDbContextFactory _dbContextFactory;
|
||||
private readonly PullPaymentHostedService _paymentHostedService;
|
||||
private readonly InvoiceRepository _invoiceRepository;
|
||||
private readonly UIInvoiceController _invoiceController;
|
||||
|
||||
public UIShopifyV2Controller
|
||||
(StoreRepository storeRepo,
|
||||
RateFetcher rateProvider,
|
||||
IConfiguration configuration,
|
||||
EventAggregator eventAggregator,
|
||||
CurrencyNameTable currencyNameTable,
|
||||
InvoiceRepository invoiceRepository,
|
||||
DefaultRulesCollection defaultRules,
|
||||
UIInvoiceController invoiceController,
|
||||
EmailSenderFactory emailSenderFactory,
|
||||
ShopifyClientFactory shopifyClientFactory,
|
||||
PayoutMethodHandlerDictionary payoutHandlers,
|
||||
ApplicationDbContextFactory dbContextFactory,
|
||||
PullPaymentHostedService paymentHostedService)
|
||||
public UIShopifyV2Controller
|
||||
(
|
||||
ShopifyClientFactory shopifyClientFactory,
|
||||
StoreRepository storeRepo,
|
||||
UIInvoiceController invoiceController,
|
||||
IConfiguration configuration,
|
||||
InvoiceRepository invoiceRepository)
|
||||
{
|
||||
_storeRepo = storeRepo;
|
||||
_rateProvider = rateProvider;
|
||||
_defaultRules = defaultRules;
|
||||
_payoutHandlers = payoutHandlers;
|
||||
_eventAggregator = eventAggregator;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_currencyNameTable = currencyNameTable;
|
||||
_invoiceRepository = invoiceRepository;
|
||||
ShopifyClientFactory = shopifyClientFactory;
|
||||
_invoiceRepository = invoiceRepository;
|
||||
_invoiceController = invoiceController;
|
||||
_emailSenderFactory = emailSenderFactory;
|
||||
ShopifyClientFactory = shopifyClientFactory;
|
||||
_paymentHostedService = paymentHostedService;
|
||||
}
|
||||
}
|
||||
public StoreData CurrentStore => HttpContext.GetStoreData();
|
||||
|
||||
public ShopifyClientFactory ShopifyClientFactory { get; }
|
||||
@ -249,8 +214,8 @@ public class UIShopifyV2Controller : Controller
|
||||
return RedirectToAction(nameof(Settings), new { storeId });
|
||||
}
|
||||
else // (command is null)
|
||||
{
|
||||
return View("/Views/UIShopify/Settings.cshtml", new ShopifySettingsViewModel()
|
||||
{
|
||||
return View("/Views/UIShopify/Settings.cshtml", new ShopifySettingsViewModel()
|
||||
{
|
||||
ClientId = settings.Setup?.ClientId,
|
||||
ClientSecret = settings.Setup?.ClientSecret,
|
||||
@ -260,106 +225,64 @@ public class UIShopifyV2Controller : Controller
|
||||
AppDeployed = settings.Setup is { DeployedCommit: {} },
|
||||
AppInstalled = settings.Setup is { AccessToken: {} },
|
||||
AppName = settings.PreferredAppName ?? ShopifyStoreSettings.DefaultAppName,
|
||||
Step = ShopifySetupStep(settings)
|
||||
});
|
||||
Step = settings switch
|
||||
{
|
||||
{ Setup: null } or { Setup: { ClientId: null, ClientSecret: null } } => ShopifySettingsViewModel.State.WaitingClientCreds,
|
||||
{ Setup: { DeployedCommit: null } } => ShopifySettingsViewModel.State.WaitingForDeploy,
|
||||
{ Setup: { AccessToken: null } } => ShopifySettingsViewModel.State.WaitingForInstall,
|
||||
_ => ShopifySettingsViewModel.State.Done
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private ShopifySettingsViewModel.State ShopifySetupStep(ShopifyStoreSettings settings)
|
||||
{
|
||||
return settings switch
|
||||
{
|
||||
{ Setup: null } or { Setup: { ClientId: null, ClientSecret: null } } => ShopifySettingsViewModel.State.WaitingClientCreds,
|
||||
{ Setup: { DeployedCommit: null } } => ShopifySettingsViewModel.State.WaitingForDeploy,
|
||||
{ Setup: { AccessToken: null } } => ShopifySettingsViewModel.State.WaitingForInstall,
|
||||
_ => ShopifySettingsViewModel.State.Done
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("~/stores/{storeId}/plugins/shopify-v2/refunds/settings")]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)]
|
||||
public async Task<IActionResult> RefundSettings(string storeId)
|
||||
[HttpGet("~/stores/{storeId}/plugins/shopify-v2/configuration")]
|
||||
public async Task<IActionResult> Configuration(string storeId)
|
||||
{
|
||||
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName) ?? new();
|
||||
settings.Setup ??= new();
|
||||
var setupState = ShopifySetupStep(settings);
|
||||
if (setupState != ShopifySettingsViewModel.State.Done)
|
||||
if (settings.Setup is { DeployedCommit: null, AccessToken: null })
|
||||
{
|
||||
this.TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Message = "You need to complete your plugin settings setup first",
|
||||
Severity = StatusMessageModel.StatusSeverity.Warning
|
||||
});
|
||||
return RedirectToAction(nameof(Settings), new { storeId });
|
||||
}
|
||||
|
||||
var client = await this.ShopifyClientFactory.CreateAPIClient(storeId);
|
||||
if (client is null)
|
||||
{
|
||||
this.TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Message = "Shopify plugin isn't configured properly",
|
||||
Message = "Kindly complete your Shopify setup",
|
||||
Severity = StatusMessageModel.StatusSeverity.Error
|
||||
});
|
||||
return RedirectToAction(nameof(Settings), new { storeId });
|
||||
return RedirectToAction(nameof(Index), new { storeId });
|
||||
}
|
||||
var shopifyAppScopes = await client.GetGrantedAccessScopes();
|
||||
var hasRequiredScope = ShopifyHostedService.HasRequiredShopifyScopes(shopifyAppScopes);
|
||||
if (!hasRequiredScope)
|
||||
{
|
||||
this.TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Message = "The Shopify plugin and app is missing required permissions for refunds. Kindly upgrade the shopify fragment to the latest and redeploy the app (Step 2)",
|
||||
Severity = StatusMessageModel.StatusSeverity.Error
|
||||
});
|
||||
return RedirectToAction(nameof(Settings), new { storeId });
|
||||
}
|
||||
|
||||
var emailSender = await _emailSenderFactory.GetEmailSender(storeId);
|
||||
var isEmailSettingsConfigured = (await emailSender.GetEmailSettings() ?? new EmailSettings()).IsComplete();
|
||||
ViewData["StoreEmailSettingsConfigured"] = isEmailSettingsConfigured;
|
||||
if (!isEmailSettingsConfigured)
|
||||
{
|
||||
this.TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Html = $"Kindly <a href='{Url.Action(action: nameof(UIStoresEmailController.StoreEmailSettings), controller: "UIStoresEmail",
|
||||
values: new { area = EmailsPlugin.Area, storeId })}' class='alert-link'>configure Email SMTP</a> to configure shopify refunds",
|
||||
Severity = StatusMessageModel.StatusSeverity.Warning
|
||||
});
|
||||
}
|
||||
Enum.TryParse<ShopifyRefundWebhookSettingsViewModel.RefundOption>(settings.Setup?.SelectedRefundOption, ignoreCase: true, out var refundOption);
|
||||
var vm = new ShopifyRefundWebhookSettingsViewModel
|
||||
return View("/Views/UIShopify/Configuration.cshtml", new ShopifySettingsViewModel()
|
||||
{
|
||||
RestockOnInvoiceExpired = settings.RestockOnInvoiceExpired,
|
||||
ClientId = settings.Setup?.ClientId,
|
||||
ClientSecret = settings.Setup?.ClientSecret,
|
||||
ShopUrl = settings.Setup?.ShopUrl,
|
||||
ShopName = GetShopName(settings.Setup?.ShopUrl),
|
||||
WebhookUrl = Url.Action(nameof(Webhook), "UIShopifyV2", new { storeId }, Request.Scheme),
|
||||
WebhookSecret = settings.Setup?.WebhookSecret,
|
||||
SpreadPercentage = settings.Setup.SpreadPercentage,
|
||||
SelectedRefundOption = refundOption
|
||||
};
|
||||
return View("/Views/UIShopify/RefundSettings.cshtml", vm);
|
||||
AppName = settings.PreferredAppName ?? ShopifyStoreSettings.DefaultAppName,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("~/stores/{storeId}/plugins/shopify-v2/refunds/settings")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> RefundSettings(string storeId, ShopifyRefundWebhookSettingsViewModel vm)
|
||||
[HttpPost("~/stores/{storeId}/plugins/shopify-v2/configuration")]
|
||||
public async Task<IActionResult> Configuration(ShopifySettingsViewModel model)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
if (CurrentStore.Id == null) return NotFound();
|
||||
|
||||
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(CurrentStore.Id, ShopifyStoreSettings.SettingsName) ?? new();
|
||||
if (settings.Setup is { DeployedCommit: null, AccessToken: null })
|
||||
{
|
||||
vm.WebhookUrl = Url.Action(nameof(Webhook), "UIShopifyV2", new { storeId }, Request.Scheme);
|
||||
return View("/Views/UIShopify/RefundSettings.cshtml", vm);
|
||||
this.TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Message = "Kindly complete your Shopify setup",
|
||||
Severity = StatusMessageModel.StatusSeverity.Error
|
||||
});
|
||||
return RedirectToAction(nameof(Settings), new { storeId = CurrentStore.Id });
|
||||
}
|
||||
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName) ?? new();
|
||||
settings.Setup ??= new();
|
||||
settings.Setup.WebhookSecret = vm.WebhookSecret;
|
||||
settings.Setup.SpreadPercentage = vm.SpreadPercentage;
|
||||
settings.Setup.SelectedRefundOption = vm.SelectedRefundOption.ToString();
|
||||
await _storeRepo.UpdateSetting<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName, settings);
|
||||
settings.RestockOnInvoiceExpired = model.RestockOnInvoiceExpired;
|
||||
await _storeRepo.UpdateSetting(CurrentStore.Id, ShopifyStoreSettings.SettingsName, settings);
|
||||
this.TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Message = "Refund settings saved successfully",
|
||||
Message = "Shopify configuration updated successfully",
|
||||
Severity = StatusMessageModel.StatusSeverity.Success
|
||||
});
|
||||
return RedirectToAction(nameof(RefundSettings), new { storeId });
|
||||
return RedirectToAction(nameof(Settings), new { storeId = CurrentStore.Id });
|
||||
}
|
||||
|
||||
static AsyncDuplicateLock OrderLocks = new AsyncDuplicateLock();
|
||||
@ -448,15 +371,8 @@ public class UIShopifyV2Controller : Controller
|
||||
Key = "btcpay_checkout_url",
|
||||
Type = "single_line_text_field",
|
||||
Value = Url.Action(nameof(Checkout), "UIShopifyV2", new { storeId, checkout_token }, Request.Scheme)
|
||||
},
|
||||
new()
|
||||
{
|
||||
Namespace = "custom",
|
||||
Key = "btcpay_invoice_id",
|
||||
Type = "single_line_text_field",
|
||||
Value = invoice.Id
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
return redirect ? RedirectToInvoiceCheckout(invoice.Id) : Ok();
|
||||
}
|
||||
@ -484,176 +400,43 @@ public class UIShopifyV2Controller : Controller
|
||||
return null;
|
||||
return o.ToString();
|
||||
}
|
||||
if (GetHeader("X-Shopify-Hmac-SHA256") is string hmac && GetHeader("X-Shopify-Topic") is string topic)
|
||||
return new WebhookInfo(hmac, topic);
|
||||
if (GetHeader("X-Shopify-Hmac-SHA256") is string hmac &&
|
||||
GetHeader("X-Shopify-Topic") is string topic &&
|
||||
GetHeader("X-Shopify-Sub-Topic") is string subtopic)
|
||||
return new WebhookInfo(hmac, $"{topic}/{subtopic}");
|
||||
return null;
|
||||
}
|
||||
|
||||
static readonly AsyncDuplicateLock RefundLocks = new AsyncDuplicateLock();
|
||||
[AllowAnonymous]
|
||||
[AllowAnonymous]
|
||||
[IgnoreAntiforgeryToken]
|
||||
[HttpPost("~/stores/{storeId}/plugins/shopify-v2/webhooks")]
|
||||
// Handles refunds, plus all forms of webhook... plus shopify requires to still listen to it...
|
||||
// We actually do not use it, but shopify requires to still listen to it...
|
||||
// leaving it here.
|
||||
public async Task<IActionResult> Webhook(string storeId)
|
||||
{
|
||||
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName);
|
||||
string requestBody;
|
||||
using (var reader = new StreamReader(Request.Body, Encoding.UTF8))
|
||||
{
|
||||
requestBody = await reader.ReadToEndAsync();
|
||||
}
|
||||
var webhookInfo = GetWebhookInfoFromHeader(Request);
|
||||
if (webhookInfo is null)
|
||||
return BadRequest("Missing webhook info in HTTP headers");
|
||||
{
|
||||
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName);
|
||||
string requestBody;
|
||||
using (var reader = new StreamReader(Request.Body, Encoding.UTF8))
|
||||
{
|
||||
requestBody = await reader.ReadToEndAsync();
|
||||
}
|
||||
var webhookInfo = GetWebhookInfoFromHeader(Request);
|
||||
if (webhookInfo is null)
|
||||
return BadRequest("Missing webhook info in HTTP headers");
|
||||
|
||||
if (string.IsNullOrEmpty(settings?.Setup?.WebhookSecret))
|
||||
return BadRequest("Webhook secret not saved yet");
|
||||
var client = await this.ShopifyClientFactory.CreateAppClient(storeId);
|
||||
if (client is null)
|
||||
return NotFound();
|
||||
if (!client.VerifyWebhookSignature(requestBody, webhookInfo.HMac))
|
||||
return Unauthorized("Invalid HMAC signature");
|
||||
|
||||
var client = await this.ShopifyClientFactory.CreateAppClient(storeId);
|
||||
if (client is null)
|
||||
return NotFound();
|
||||
if (!client.VerifyWebhookSignature(requestBody, webhookInfo.HMac, settings.Setup.WebhookSecret))
|
||||
return Unauthorized("Invalid HMAC signature");
|
||||
// https://shopify.dev/docs/api/webhooks?reference=toml#list-of-topics-orders/create
|
||||
//if (webhookInfo.FullTopicName == "orders/create")
|
||||
//{
|
||||
// var order = JsonConvert.DeserializeObject<dynamic>(requestBody)!;
|
||||
// checkoutTokens.Add(new(storeId, (string)order.checkout_token), (long)order.id);
|
||||
//}
|
||||
|
||||
// https://shopify.dev/docs/api/webhooks?reference=toml#list-of-topics-orders/create
|
||||
//if (webhookInfo.FullTopicName == "orders/create")
|
||||
//{
|
||||
// var order = JsonConvert.DeserializeObject<dynamic>(requestBody)!;
|
||||
// checkoutTokens.Add(new(storeId, (string)order.checkout_token), (long)order.id);
|
||||
//}
|
||||
|
||||
if (webhookInfo.FullTopicName == "refunds/create")
|
||||
{
|
||||
return await HandleRefundCreateWebhook(storeId, requestBody, settings);
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleRefundCreateWebhook(string storeId, string requestBody, ShopifyStoreSettings settings)
|
||||
{
|
||||
var store = await _storeRepo.FindStore(storeId);
|
||||
var client = await this.ShopifyClientFactory.CreateAPIClient(storeId);
|
||||
if (settings?.Setup == null || store == null || client == null)
|
||||
return BadRequest("Store isn't registered or refunds isn't configured with shopify plugin");
|
||||
|
||||
// https://shopify.dev/docs/api/webhooks/latest?accordionItem=webhooks-refunds-create&reference=toml
|
||||
var refundPayload = JsonConvert.DeserializeObject<ShopifyRefundWebhook>(requestBody);
|
||||
if (refundPayload == null || refundPayload.OrderId <= 0)
|
||||
return BadRequest("Invalid refund payload");
|
||||
|
||||
// refund_line_items contains the actual product to be refunded refunds
|
||||
var lineItemsRefundAmount = refundPayload.RefundLineItems.Sum(item => Math.Abs(item.Subtotal));
|
||||
|
||||
// order_adjustments contains shipping refunds, restocking fees, and discrepancies
|
||||
var adjustmentsRefundAmount = refundPayload.OrderAdjustments
|
||||
.Where(adj => adj.RefundId.HasValue && adj.RefundId.Value > 0).Sum(adj => Math.Abs(adj.Amount));
|
||||
|
||||
var totalRefundAmount = lineItemsRefundAmount + adjustmentsRefundAmount;
|
||||
if (totalRefundAmount <= 0)
|
||||
return BadRequest("No valid refund amount found in order adjustments");
|
||||
|
||||
var emailSender = await _emailSenderFactory.GetEmailSender(storeId);
|
||||
var isEmailSettingsConfigured = (await emailSender.GetEmailSettings() ?? new EmailSettings()).IsComplete();
|
||||
if (!isEmailSettingsConfigured)
|
||||
return BadRequest("Email Server not configured for store");
|
||||
|
||||
using var l = await RefundLocks.LockAsync(refundPayload.OrderId, CancellationToken.None);
|
||||
|
||||
if (await client.GetOrder(refundPayload.OrderId, true) is not { } order)
|
||||
return BadRequest("Order is invalid");
|
||||
|
||||
var containsKeyword = order.PaymentGatewayNames.Any(pgName => ShopifyHostedService.IsBTCPayServerGateway(pgName));
|
||||
if (!containsKeyword)
|
||||
return NotFound("Order wasn't fulfilled with BTCPay Server payment option");
|
||||
|
||||
if (order.BtcpayInvoiceId == null || string.IsNullOrEmpty(order.BtcpayInvoiceId?.Value))
|
||||
return NotFound("BTCPay invoice ID not found in order metadata");
|
||||
|
||||
var invoice = await _invoiceRepository.GetInvoice(order.BtcpayInvoiceId.Value);
|
||||
if (invoice == null)
|
||||
return BadRequest("No invoice matching this criteria");
|
||||
|
||||
if ((invoice.Refunds != null && invoice.Refunds.Any()) || !invoice.GetInvoiceState().CanRefund())
|
||||
return BadRequest("Cannot process invoice refund at the moment, as invoice either has active refunds or invoice state cannot process refund");
|
||||
|
||||
var supportedPmis = _payoutHandlers.GetSupportedPayoutMethods(store)?.ToArray();
|
||||
if (supportedPmis == null || !supportedPmis.Any())
|
||||
return BadRequest("No supported payout methods configured for store");
|
||||
|
||||
var paymentMethodId = invoice.GetClosestPaymentMethodId(supportedPmis);
|
||||
var paymentMethod = paymentMethodId == null ? null : invoice.GetPaymentPrompt(paymentMethodId);
|
||||
if (paymentMethod?.Currency == null)
|
||||
return BadRequest("Invalid payout method");
|
||||
|
||||
RateResult rateResult = await _rateProvider.FetchRate(new CurrencyPair(paymentMethod.Currency, invoice.Currency),
|
||||
store.GetStoreBlob().GetRateRules(_defaultRules), new StoreIdRateContext(store.Id), CancellationToken.None);
|
||||
if (rateResult.BidAsk == null)
|
||||
return BadRequest($"Unable to fetch rate {rateResult.EvaluatedRule}");
|
||||
|
||||
CreatePullPaymentRequest createPullPayment = new CreatePullPaymentRequest
|
||||
{
|
||||
Name = $"Refund {invoice.Id}",
|
||||
PayoutMethods = supportedPmis.Select(c => c.ToString()).ToArray(),
|
||||
AutoApproveClaims = true,
|
||||
BOLT11Expiration = store.GetStoreBlob().RefundBOLT11Expiration,
|
||||
Description = $"Refund for shopify order {refundPayload.OrderId}. Amount refunded {totalRefundAmount} {invoice.Currency}",
|
||||
};
|
||||
switch (settings.Setup.SelectedRefundOption)
|
||||
{
|
||||
case "RateThen":
|
||||
createPullPayment.Currency = paymentMethod.Currency;
|
||||
createPullPayment.Amount = Math.Round(totalRefundAmount / paymentMethod.Rate, paymentMethod.Divisibility);
|
||||
break;
|
||||
|
||||
case "CurrentRate":
|
||||
createPullPayment.Currency = paymentMethod.Currency;
|
||||
createPullPayment.Amount = Math.Round(totalRefundAmount / rateResult.BidAsk.Bid, paymentMethod.Divisibility);
|
||||
break;
|
||||
|
||||
default:
|
||||
return BadRequest("No refund option configured in plugin");
|
||||
}
|
||||
|
||||
if (settings.Setup.SpreadPercentage is > 0 and <= 99)
|
||||
{
|
||||
var reduceByAmount = createPullPayment.Amount * (settings.Setup.SpreadPercentage / 100);
|
||||
createPullPayment.Amount = Math.Round(createPullPayment.Amount - reduceByAmount, paymentMethod.Divisibility);
|
||||
if (createPullPayment.Amount <= 0)
|
||||
{
|
||||
return BadRequest($"Refund amount becomes zero or negative after applying spread percentage of {settings.Setup.SpreadPercentage}%. Please reduce the spread percentage.");
|
||||
}
|
||||
}
|
||||
await using var ctx = _dbContextFactory.CreateContext();
|
||||
var ppId = await _paymentHostedService.CreatePullPayment(store, createPullPayment);
|
||||
ctx.Refunds.Add(new RefundData
|
||||
{
|
||||
InvoiceDataId = invoice.Id,
|
||||
PullPaymentDataId = ppId
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var claimUrl = Url.Action(
|
||||
action: nameof(UIPullPaymentController.ViewPullPayment),
|
||||
controller: "UIPullPayment",
|
||||
values: new { pullPaymentId = ppId },
|
||||
protocol: Request.Scheme
|
||||
);
|
||||
var customer = order.Customer;
|
||||
var model = new JObject
|
||||
{
|
||||
["RefundLink"] = claimUrl,
|
||||
["Order"] = new JObject
|
||||
{
|
||||
["Id"] = refundPayload.OrderId.ToString()
|
||||
},
|
||||
["Customer"] = new JObject
|
||||
{
|
||||
["Email"] = customer?.DefaultEmailAddress?.EmailAddress ?? "",
|
||||
["Name"] = customer?.DisplayName ?? ""
|
||||
}
|
||||
};
|
||||
_eventAggregator.Publish(new TriggerEvent(storeId, ShopifyMailTriggers.RefundCreated, model, null));
|
||||
return Ok();
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Plugins.Emails;
|
||||
using BTCPayServer.Plugins.Emails.Views;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@ -11,47 +9,13 @@ public class Plugin : BaseBTCPayServerPlugin
|
||||
{
|
||||
public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } =
|
||||
{
|
||||
new IBTCPayServerPlugin.PluginDependency { Identifier = nameof(BTCPayServer), Condition = ">=2.3.3" }
|
||||
new IBTCPayServerPlugin.PluginDependency { Identifier = nameof(BTCPayServer), Condition = ">=2.0.0" }
|
||||
};
|
||||
|
||||
public override void Execute(IServiceCollection services)
|
||||
{
|
||||
services.AddUIExtension("header-nav", "ShopifyPluginHeaderNav");
|
||||
services.AddSingleton<ShopifyClientFactory>();
|
||||
services.AddSingleton<ShopifyClientFactory>();
|
||||
services.AddHostedService<ShopifyHostedService>();
|
||||
RegisterShopifyEmailTriggers(services);
|
||||
}
|
||||
|
||||
|
||||
private void RegisterShopifyEmailTriggers(IServiceCollection services)
|
||||
{
|
||||
var vm = new EmailTriggerViewModel()
|
||||
{
|
||||
Trigger = ShopifyMailTriggers.RefundCreated,
|
||||
DefaultEmail = new()
|
||||
{
|
||||
To = ["{Customer.Email}"],
|
||||
Subject = "Refund for Shopify Order #{Order.Id}",
|
||||
Body = EmailsPlugin.CreateEmail(
|
||||
"Hello {Customer.Name},<br/><br/>A refund has been created for your shopify order #{Order.Id}.<br/><br/>You can claim your refund using the link below.",
|
||||
"Claim Refund",
|
||||
"{RefundLink}"
|
||||
),
|
||||
},
|
||||
PlaceHolders = new()
|
||||
{
|
||||
new("{RefundLink}", "The link to claim the refund"),
|
||||
new("{Order.Id}", "The Shopify order ID"),
|
||||
new("{Customer.Email}", "The customer's email address"),
|
||||
new("{Customer.Name}", "The customer's name"),
|
||||
},
|
||||
Description = "Shopify: Refund Created",
|
||||
};
|
||||
services.AddSingleton(vm);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ShopifyMailTriggers
|
||||
{
|
||||
public const string RefundCreated = "ShopifyRefundCreated";
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@ -11,6 +10,7 @@ using BTCPayServer.Logging;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.Clients;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@ -18,16 +18,19 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Services;
|
||||
|
||||
public class ShopifyHostedService : EventHostedServiceBase
|
||||
{
|
||||
private readonly StoreRepository _storeRepo;
|
||||
private readonly CurrencyNameTable _currencyNameTable;
|
||||
private readonly InvoiceRepository _invoiceRepository;
|
||||
private readonly ShopifyClientFactory shopifyClientFactory;
|
||||
|
||||
public ShopifyHostedService(EventAggregator eventAggregator,
|
||||
public ShopifyHostedService(StoreRepository storeRepo,
|
||||
EventAggregator eventAggregator,
|
||||
InvoiceRepository invoiceRepository,
|
||||
CurrencyNameTable currencyNameTable,
|
||||
ShopifyClientFactory shopifyClientFactory,
|
||||
Logs logs) : base(eventAggregator, logs)
|
||||
{
|
||||
_storeRepo = storeRepo;
|
||||
_currencyNameTable = currencyNameTable;
|
||||
_invoiceRepository = invoiceRepository;
|
||||
this.shopifyClientFactory = shopifyClientFactory;
|
||||
@ -72,13 +75,8 @@ public class ShopifyHostedService : EventHostedServiceBase
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] _requiredAppScopes = new[] { "read_orders", "write_orders", "read_customers" };
|
||||
public static bool HasRequiredShopifyScopes(IEnumerable<string> grantedScopes)
|
||||
{
|
||||
return _requiredAppScopes.All(required => grantedScopes.Contains(required, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string[] _keywords = new[] { "bitcoin", "btc", "btcpayserver", "btcpay server" };
|
||||
|
||||
public static bool IsBTCPayServerGateway(string gateway)
|
||||
{
|
||||
return _keywords.Any(keyword => gateway.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||||
@ -88,7 +86,8 @@ public class ShopifyHostedService : EventHostedServiceBase
|
||||
{
|
||||
var logs = new InvoiceLogs();
|
||||
var client = await shopifyClientFactory.CreateAPIClient(invoice.StoreId);
|
||||
if (client is null)
|
||||
var shopifySettings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(invoice.StoreId, ShopifyStoreSettings.SettingsName);
|
||||
if (client is null || shopifySettings is null)
|
||||
return logs;
|
||||
if (await client.GetOrder(shopifyOrderId, true) is not { } order)
|
||||
return logs;
|
||||
@ -158,8 +157,8 @@ public class ShopifyHostedService : EventHostedServiceBase
|
||||
OrderId = order.Id,
|
||||
NotifyCustomer = false,
|
||||
Reason = OrderCancelReason.DECLINED,
|
||||
Restock = true,
|
||||
Refund = false,
|
||||
Restock = shopifySettings.RestockOnInvoiceExpired,
|
||||
Refund = false,
|
||||
StaffNote = $"BTCPay Invoice {invoice.Id} is {invoice.Status}"
|
||||
});
|
||||
logs.Write($"Shopify order cancelled. (Invoice Status: {invoice.Status})", InvoiceEventData.EventSeverity.Warning);
|
||||
|
||||
@ -11,7 +11,8 @@ namespace BTCPayServer.Plugins.ShopifyPlugin
|
||||
}
|
||||
public const string SettingsName = "ShopifyPluginSettings";
|
||||
public const string DefaultAppName = "BTCPay Server";
|
||||
}
|
||||
public bool RestockOnInvoiceExpired { get; set; } = true;
|
||||
}
|
||||
|
||||
public class ShopifySetupSettings
|
||||
{
|
||||
@ -24,8 +25,5 @@ namespace BTCPayServer.Plugins.ShopifyPlugin
|
||||
/// </summary>
|
||||
public string? Version { get; set; }
|
||||
public string? DeployedCommit { get; set; }
|
||||
public string? WebhookSecret { get; set; }
|
||||
public string? SelectedRefundOption { get; set; }
|
||||
public decimal SpreadPercentage { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
|
||||
|
||||
public class ShopifyRefundWebhook
|
||||
{
|
||||
[JsonProperty("order_id")]
|
||||
public long OrderId { get; set; }
|
||||
|
||||
[JsonProperty("refund_line_items")]
|
||||
public List<ShopifyRefundLineItem> RefundLineItems { get; set; } = new();
|
||||
|
||||
[JsonProperty("order_adjustments")]
|
||||
public List<ShopifyOrderAdjustment> OrderAdjustments { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ShopifyRefundLineItem
|
||||
{
|
||||
[JsonProperty("subtotal")]
|
||||
public decimal Subtotal { get; set; }
|
||||
}
|
||||
|
||||
public class ShopifyOrderAdjustment
|
||||
{
|
||||
[JsonProperty("refund_id")]
|
||||
public long? RefundId { get; set; }
|
||||
|
||||
[JsonProperty("amount")]
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
public class AccessScopeHandle
|
||||
{
|
||||
[JsonProperty("handle")]
|
||||
public string Handle { get; set; }
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
|
||||
|
||||
public class ShopifyRefundWebhookSettingsViewModel
|
||||
{
|
||||
public enum RefundOption
|
||||
{
|
||||
CurrentRate,
|
||||
RateThen
|
||||
}
|
||||
public string ShopName { get; set; }
|
||||
public string WebhookUrl { get; set; }
|
||||
public string WebhookSecret { get; set; }
|
||||
[Required(ErrorMessage = "Please select a refund option")]
|
||||
public RefundOption SelectedRefundOption { get; set; } = RefundOption.CurrentRate;
|
||||
|
||||
[Range(0, 99, ErrorMessage = "Spread must be between 0 and 99")]
|
||||
public decimal SpreadPercentage { get; set; } = 0;
|
||||
}
|
||||
@ -26,5 +26,6 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels
|
||||
public string CLIToken { get; set; }
|
||||
public string ShopUrl { get; set; }
|
||||
public string ShopName { get; set; }
|
||||
public bool RestockOnInvoiceExpired { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
@using BTCPayServer.Abstractions.Contracts
|
||||
@using BTCPayServer.Abstractions.Extensions
|
||||
@using BTCPayServer.Plugins.ShopifyPlugin
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@inject IScopeProvider ScopeProvider
|
||||
@{
|
||||
@ -10,15 +9,9 @@
|
||||
@if (!string.IsNullOrEmpty(storeId))
|
||||
{
|
||||
<li class="nav-item">
|
||||
<a layout-menu-item="Shopify-Settings" asp-controller="UIShopifyV2" asp-action="Settings" asp-route-storeId="@storeId">
|
||||
<a asp-controller="UIShopifyV2" asp-action="Settings" asp-route-storeId="@storeId" class="nav-link @ViewData.ActivePageClass("Shopify")">
|
||||
<svg role="img" class="icon icon-plugin" viewBox="0 0 32 32" fill="none"><path transform="scale(.7) translate(5, 5)" d="m20.45 31.97 9.62-2.08-3.5-23.64c-.03-.16-.15-.26-.28-.26l-2.57-.18s-1.7-1.7-1.92-1.88a.41.41 0 0 0-.16-.1l-1.22 28.14zm-4.83-16.9s-1.09-.56-2.37-.56c-1.93 0-2 1.2-2 1.52 0 1.64 4.31 2.29 4.31 6.17 0 3.06-1.92 5.01-4.54 5.01-3.14 0-4.72-1.95-4.72-1.95l.86-2.78s1.66 1.42 3.04 1.42c.9 0 1.3-.72 1.3-1.24 0-2.16-3.54-2.26-3.54-5.81-.04-2.98 2.1-5.9 6.44-5.9 1.68 0 2.5.49 2.5.49l-1.26 3.62zM14.9 1.1c.17 0 .36.06.53.19-1.31.62-2.75 2.18-3.34 5.32-.88.28-1.73.54-2.52.77.69-2.38 2.36-6.26 5.33-6.26zm1.64 3.94v.18l-3.2.98c.63-2.37 1.79-3.53 2.79-3.96.26.67.41 1.57.41 2.8zm.72-2.98c.92.1 1.52 1.15 1.9 2.34-.46.15-.98.3-1.54.49v-.34c0-1-.13-1.82-.36-2.5zm3.99 1.72-.1.03c-.03 0-.39.1-.96.28-.56-1.65-1.56-3.16-3.34-3.16h-.16C16.2.28 15.56 0 15.02 0 10.88 0 8.9 5.17 8.28 7.8c-1.6.48-2.75.84-2.88.9-.9.28-.93.3-1.03 1.15-.1.62-2.44 18.75-2.44 18.75L20.01 32z" fill="currentColor" /></svg>
|
||||
<span>Shopify v2</span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
@if (ViewData.IsCategory("ShopifyV2"))
|
||||
{
|
||||
<li class="nav-item nav-item-sub">
|
||||
<a layout-menu-item="Shopify-Refund" asp-controller="UIShopifyV2" asp-action="RefundSettings" asp-route-storeId="@storeId" text-translate="true">Refund Settings</a>
|
||||
</li>
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
@using BTCPayServer.Abstractions.Extensions
|
||||
@using BTCPayServer.Plugins.ShopifyPlugin.ViewModels
|
||||
@using Microsoft.AspNetCore.Routing
|
||||
@using BTCPayServer.Plugins.ShopifyPlugin.Blazor
|
||||
@model ShopifySettingsViewModel
|
||||
@{
|
||||
ViewData.SetActivePage("Shopify", "Shopify App Configuration", "Shopify");
|
||||
}
|
||||
|
||||
<form method="post" asp-action="Configuration" asp-controller="UIShopifyV2">
|
||||
<div class="sticky-header d-flex align-items-center justify-content-between">
|
||||
<h2>@ViewData["Title"]</h2>
|
||||
<button type="submit" class="btn btn-primary" id="Edit" value="Save Changes">Save Changes</button>
|
||||
</div>
|
||||
|
||||
<partial name="_StatusMessage" />
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xl-10 col-xxl-constrain">
|
||||
<div class="form-group d-flex align-items-center">
|
||||
<input asp-for="RestockOnInvoiceExpired" type="checkbox" class="btcpay-toggle me-3" />
|
||||
<label asp-for="RestockOnInvoiceExpired" class="form-label mb-0 me-1">Restock product items when invoice expires</label>
|
||||
<span asp-validation-for="RestockOnInvoiceExpired" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@ -1,103 +0,0 @@
|
||||
@using BTCPayServer.Abstractions.Contracts
|
||||
@using BTCPayServer.Abstractions.Extensions
|
||||
@using BTCPayServer.Abstractions.Models
|
||||
@using BTCPayServer.Plugins.ShopifyPlugin
|
||||
@using BTCPayServer.Plugins.ShopifyPlugin.ViewModels
|
||||
@using Microsoft.AspNetCore.Routing
|
||||
@using BTCPayServer.Plugins.ShopifyPlugin.Blazor
|
||||
@inject IScopeProvider ScopeProvider
|
||||
@model ShopifyRefundWebhookSettingsViewModel
|
||||
@{
|
||||
var storeId = ScopeProvider.GetCurrentStoreId();
|
||||
ViewData.SetLayoutModel(new LayoutModel("Shopify-Refund", "Shopify Refund Settings") { ActiveCategory = "ShopifyV2" });
|
||||
var storeEmailSettingsConfigured = (bool)ViewData["StoreEmailSettingsConfigured"];
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<div class="sticky-header">
|
||||
<h2 class="my-1">
|
||||
@ViewData["Title"]
|
||||
<small>
|
||||
<a href="https://docs.btcpayserver.org/ShopifyV2/#shopify-refunds" target="_blank" rel="noreferrer noopener"
|
||||
title="@StringLocalizer["More information..."]">
|
||||
<vc:icon symbol="info" />
|
||||
</a>
|
||||
</small>
|
||||
</h2>
|
||||
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
||||
<button type="submit" form="refundSettingsForm" class="btn btn-primary" @(storeEmailSettingsConfigured ? "" : "disabled")>
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<partial name="_StatusMessage" />
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="alert alert-info">
|
||||
<p class="mb-2">
|
||||
This webhook notifies BTCPay Server when a refund occurs for orders paid via BTCPay Server,
|
||||
and sends a secure claim link to the customer by email.
|
||||
</p>
|
||||
<p class="mb-2">
|
||||
Copy the webhook URL below and create a webhook via
|
||||
<a href="https://admin.shopify.com/store/@(Model.ShopName)/settings/notifications/webhooks"
|
||||
target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: underline;">
|
||||
this link</a>.
|
||||
Use the settings below:
|
||||
</p>
|
||||
<ul class="mb-2">
|
||||
<li>Event: <strong>Refund create</strong></li>
|
||||
<li>Format: <strong>JSON</strong></li>
|
||||
<li>Webhook API version: <strong>2026-01</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form id="refundSettingsForm" asp-action="RefundSettings" asp-route-storeId="@storeId" method="post">
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold">Webhook URL</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" value="@Model.WebhookUrl" readonly />
|
||||
<button type="button" class="btn btn-outline-secondary clipboard-button" data-clipboard="@Model.WebhookUrl">
|
||||
<vc:icon symbol="actions-copy" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label asp-for="WebhookSecret" class="form-label fw-bold">Webhook Signature</label>
|
||||
<input asp-for="WebhookSecret" class="form-control" placeholder="Enter your Shopify webhook secret" />
|
||||
<span asp-validation-for="WebhookSecret" class="text-danger"></span>
|
||||
<div class="form-text">
|
||||
Your webhook secret is displayed on the webhook page
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold">Refund Option Method</label>
|
||||
<span asp-validation-for="SelectedRefundOption" class="text-danger"></span>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" asp-for="SelectedRefundOption" value="CurrentRate" id="refundCurrentRate" />
|
||||
<label class="form-check-label" for="refundCurrentRate"> Use current exchange rate at time of refund</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" asp-for="SelectedRefundOption" value="RateThen" id="refundRateThen" />
|
||||
<label class="form-check-label" for="refundRateThen">Use exchange rate at time of original payment</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label asp-for="SpreadPercentage" class="form-label fw-bold">Refund Spread Percentage</label>
|
||||
<div class="input-group">
|
||||
<input asp-for="SpreadPercentage" type="number" class="form-control" step="0.01" min="0" max="100" placeholder="e.g. 2.5" />
|
||||
<span asp-validation-for="SpreadPercentage" class="text-danger"></span>
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
Optional. This percentage will be deducted from the refund amount to account for exchange rate volatility.
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,12 +1,10 @@
|
||||
@using BTCPayServer.Abstractions.Extensions
|
||||
@using BTCPayServer.Abstractions.Models
|
||||
@using BTCPayServer.Plugins.ShopifyPlugin
|
||||
@using BTCPayServer.Plugins.ShopifyPlugin.ViewModels
|
||||
@using Microsoft.AspNetCore.Routing
|
||||
@using BTCPayServer.Plugins.ShopifyPlugin.Blazor
|
||||
@model ShopifySettingsViewModel
|
||||
@{
|
||||
ViewData.SetLayoutModel(new LayoutModel("Shopify-Settings", "Shopify Settings") { ActiveCategory = "ShopifyV2" });
|
||||
ViewData.SetActivePage("Shopify", "Update Shopify Plugin Settings", "Shopify");
|
||||
Layout = "_Layout";
|
||||
bool shopifyCredsSet = Model.Step != ShopifySettingsViewModel.State.WaitingClientCreds;
|
||||
var storeId = (string)this.Context.GetRouteValue("storeId");
|
||||
@ -34,6 +32,10 @@
|
||||
</small>
|
||||
</h2>
|
||||
<div>
|
||||
@if (Model.Step == ShopifySettingsViewModel.State.Done)
|
||||
{
|
||||
<a asp-controller="UIShopifyV2" asp-action="Configuration" asp-route-storeId="@storeId" class="btn btn-primary">Configuration</a>
|
||||
}
|
||||
@if (Model.Step != ShopifySettingsViewModel.State.WaitingClientCreds)
|
||||
{
|
||||
<form method="post">
|
||||
|
||||
@ -26,7 +26,7 @@ services:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
shopify-app-deployer:
|
||||
image: btcpayserver/shopify-app-deployer:1.6
|
||||
image: btcpayserver/shopify-app-deployer:1.5
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
expose:
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit aaa244fec37b5d258cd7c2581ba28358b80e3f3e
|
||||
Subproject commit fcd49ff729943bb04cc4958dae58b47424c0bcdb
|
||||
Loading…
Reference in New Issue
Block a user