diff --git a/BTCPayServer/Controllers/InvoiceController.cs b/BTCPayServer/Controllers/InvoiceController.cs index c4eba1e73..d86a4f862 100644 --- a/BTCPayServer/Controllers/InvoiceController.cs +++ b/BTCPayServer/Controllers/InvoiceController.cs @@ -175,6 +175,8 @@ namespace BTCPayServer.Controllers excludeFilter = PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p)); } entity.PaymentTolerance = invoice.Checkout.PaymentTolerance ?? storeBlob.PaymentTolerance; + if (additionalTags != null) + entity.InternalTags.AddRange(additionalTags); return await CreateInvoiceCoreRaw(entity, store, excludeFilter, cancellationToken); } diff --git a/BTCPayServer/Controllers/StoresController.Integrations.cs b/BTCPayServer/Controllers/StoresController.Integrations.cs new file mode 100644 index 000000000..6230d549b --- /dev/null +++ b/BTCPayServer/Controllers/StoresController.Integrations.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using BTCPayServer.Data; +using BTCPayServer.Models.StoreViewModels; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Shopify; +using BTCPayServer.Services.Shopify.ApiModels; +using BTCPayServer.Services.Shopify.Models; +using BTCPayServer.Services.Stores; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json.Linq; +using NicolasDorier.RateLimits; + +namespace BTCPayServer.Controllers +{ + public partial class StoresController + { + private static string _cachedShopifyJavascript; + + private async Task GetJavascript() + { + if (!string.IsNullOrEmpty(_cachedShopifyJavascript) && !_BTCPayEnv.IsDeveloping) + { + return _cachedShopifyJavascript; + } + + string[] fileList = _BtcpayServerOptions.BundleJsCss + ? new[] {"bundles/shopify-bundle.min.js"} + : new[] {"modal/btcpay.js", "shopify/btcpay-shopify.js"}; + + + foreach (var file in fileList) + { + await using var stream = _webHostEnvironment.WebRootFileProvider + .GetFileInfo(file).CreateReadStream(); + using var reader = new StreamReader(stream); + _cachedShopifyJavascript += Environment.NewLine + await reader.ReadToEndAsync(); + } + + return _cachedShopifyJavascript; + } + + [AllowAnonymous] + [HttpGet("{storeId}/integrations/shopify/shopify.js")] + public async Task ShopifyJavascript(string storeId) + { + var jsFile = + $"var BTCPAYSERVER_URL = \"{Request.GetAbsoluteRoot()}\"; var STORE_ID = \"{storeId}\"; {await GetJavascript()}"; + return Content(jsFile, "text/javascript"); + } + + [RateLimitsFilter(ZoneLimits.Shopify, Scope = RateLimitsScope.RemoteAddress)] + [AllowAnonymous] + [EnableCors(CorsPolicies.All)] + [HttpGet("{storeId}/integrations/shopify/{orderId}")] + public async Task ShopifyInvoiceEndpoint( + [FromServices] InvoiceRepository invoiceRepository, + [FromServices] InvoiceController invoiceController, + [FromServices] IHttpClientFactory httpClientFactory, + string storeId, string orderId, decimal amount, bool checkOnly = false) + { + var invoiceOrderId = $"{ShopifyOrderMarkerHostedService.SHOPIFY_ORDER_ID_PREFIX}{orderId}"; + var matchedExistingInvoices = await invoiceRepository.GetInvoices(new InvoiceQuery() + { + OrderId = new[] {invoiceOrderId}, StoreId = new[] {storeId} + }); + matchedExistingInvoices = matchedExistingInvoices.Where(entity => + entity.GetInternalTags(ShopifyOrderMarkerHostedService.SHOPIFY_ORDER_ID_PREFIX) + .Any(s => s == orderId)) + .ToArray(); + + var firstInvoiceStillPending = + matchedExistingInvoices.FirstOrDefault(entity => entity.GetInvoiceState().Status == InvoiceStatus.New); + if (firstInvoiceStillPending != null) + { + return Ok(new + { + invoiceId = firstInvoiceStillPending.Id, + status = firstInvoiceStillPending.Status.ToString().ToLowerInvariant() + }); + } + + var firstInvoiceSettled = + matchedExistingInvoices.LastOrDefault(entity => + new[] {InvoiceStatus.Paid, InvoiceStatus.Complete, InvoiceStatus.Confirmed}.Contains( + entity.GetInvoiceState().Status)); + + var store = await _Repo.FindStore(storeId); + var shopify = store?.GetStoreBlob()?.Shopify; + ShopifyApiClient client = null; + ShopifyOrder order = null; + if (shopify?.IntegratedAt.HasValue is true) + { + client = new ShopifyApiClient(httpClientFactory, shopify.CreateShopifyApiCredentials()); + order = await client.GetOrder(orderId); + if (string.IsNullOrEmpty(order?.Id)) + { + return NotFound(); + } + } + + if (firstInvoiceSettled != null) + { + //if BTCPay was shut down before the tx managed to get registered on shopify, this will fix it on the next UI load in shopify + if (client != null && order?.FinancialStatus == "pending" && + firstInvoiceSettled.Status != InvoiceStatus.Paid) + { + await new OrderTransactionRegisterLogic(client).Process(orderId, firstInvoiceSettled.Id, + firstInvoiceSettled.Currency, + firstInvoiceSettled.Price.ToString(CultureInfo.InvariantCulture), true); + order = await client.GetOrder(orderId); + } + + if (order?.FinancialStatus != "pending" && order?.FinancialStatus != "partially_paid") + { + return Ok(new + { + invoiceId = firstInvoiceSettled.Id, + status = firstInvoiceSettled.Status.ToString().ToLowerInvariant() + }); + } + } + + if (checkOnly) + { + return Ok(); + } + + if (shopify?.IntegratedAt.HasValue is true) + { + if (string.IsNullOrEmpty(order?.Id) || + !new[] {"pending", "partially_paid"}.Contains(order.FinancialStatus)) + { + return NotFound(); + } + + //we create the invoice at due amount provided from order page or full amount if due amount is bigger than order amount + var invoice = await invoiceController.CreateInvoiceCoreRaw( + new CreateInvoiceRequest() + { + Amount = amount < order.TotalPrice ? amount : order.TotalPrice, + Currency = order.Currency, + Metadata = new JObject {["orderId"] = invoiceOrderId} + }, store, + Request.GetAbsoluteUri(""), new List() {invoiceOrderId}); + + return Ok(new {invoiceId = invoice.Id, status = invoice.Status.ToString().ToLowerInvariant()}); + } + + return NotFound(); + } + + + [HttpGet] + [Route("{storeId}/integrations")] + [Route("{storeId}/integrations/shopify")] + public IActionResult Integrations() + { + var blob = CurrentStore.GetStoreBlob(); + + var vm = new IntegrationsViewModel {Shopify = blob.Shopify}; + + return View("Integrations", vm); + } + + [HttpPost] + [Route("{storeId}/integrations/shopify")] + public async Task Integrations([FromServices] IHttpClientFactory clientFactory, + IntegrationsViewModel vm, string command = "", string exampleUrl = "") + { + if (!string.IsNullOrEmpty(exampleUrl)) + { + try + { + //https://{apikey}:{password}@{hostname}/admin/api/{version}/{resource}.json + var parsedUrl = new Uri(exampleUrl); + var userInfo = parsedUrl.UserInfo.Split(":"); + vm.Shopify = new ShopifySettings() + { + ApiKey = userInfo[0], + Password = userInfo[1], + ShopName = parsedUrl.Host.Replace(".myshopify.com", "", + StringComparison.InvariantCultureIgnoreCase) + }; + command = "ShopifySaveCredentials"; + } + catch (Exception) + { + TempData[WellKnownTempData.ErrorMessage] = "The provided Example Url was invalid."; + return View("Integrations", vm); + } + } + + switch (command) + { + case "ShopifySaveCredentials": + { + var shopify = vm.Shopify; + var validCreds = shopify != null && shopify?.CredentialsPopulated() == true; + if (!validCreds) + { + TempData[WellKnownTempData.ErrorMessage] = "Please provide valid Shopify credentials"; + return View("Integrations", vm); + } + + var apiClient = new ShopifyApiClient(clientFactory, shopify.CreateShopifyApiCredentials()); + try + { + await apiClient.OrdersCount(); + } + catch (ShopifyApiException) + { + TempData[WellKnownTempData.ErrorMessage] = + "Shopify rejected provided credentials, please correct values and try again"; + return View("Integrations", vm); + } + + var scopesGranted = await apiClient.CheckScopes(); + if (!scopesGranted.Contains("read_orders") || !scopesGranted.Contains("write_orders")) + { + TempData[WellKnownTempData.ErrorMessage] = + "Please grant the private app permissions for read_orders, write_orders"; + return View("Integrations", vm); + } + + // everything ready, proceed with saving Shopify integration credentials + shopify.IntegratedAt = DateTimeOffset.Now; + + var blob = CurrentStore.GetStoreBlob(); + blob.Shopify = shopify; + if (CurrentStore.SetStoreBlob(blob)) + { + await _Repo.UpdateStore(CurrentStore); + } + + TempData[WellKnownTempData.SuccessMessage] = "Shopify integration successfully updated"; + break; + } + case "ShopifyClearCredentials": + { + var blob = CurrentStore.GetStoreBlob(); + blob.Shopify = null; + if (CurrentStore.SetStoreBlob(blob)) + { + await _Repo.UpdateStore(CurrentStore); + } + + TempData[WellKnownTempData.SuccessMessage] = "Shopify integration credentials cleared"; + break; + } + } + + return RedirectToAction(nameof(Integrations), new {storeId = CurrentStore.Id}); + } + } +} diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index dda4feff4..b828e9514 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -20,8 +20,10 @@ using BTCPayServer.Services; using BTCPayServer.Services.Apps; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Rates; +using BTCPayServer.Services.Shopify; using BTCPayServer.Services.Stores; using BTCPayServer.Services.Wallets; +using BundlerMinifier.TagHelpers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; @@ -54,15 +56,14 @@ namespace BTCPayServer.Controllers BTCPayNetworkProvider networkProvider, RateFetcher rateFactory, ExplorerClientProvider explorerProvider, - IFeeProviderFactory feeRateProvider, LanguageService langService, - IWebHostEnvironment env, IHttpClientFactory httpClientFactory, PaymentMethodHandlerDictionary paymentMethodHandlerDictionary, SettingsRepository settingsRepository, IAuthorizationService authorizationService, EventAggregator eventAggregator, CssThemeManager cssThemeManager, - AppService appService) + AppService appService, + IWebHostEnvironment webHostEnvironment) { _RateFactory = rateFactory; _Repo = repo; @@ -71,17 +72,15 @@ namespace BTCPayServer.Controllers _LangService = langService; _TokenController = tokenController; _WalletProvider = walletProvider; - _Env = env; - _httpClientFactory = httpClientFactory; _paymentMethodHandlerDictionary = paymentMethodHandlerDictionary; _settingsRepository = settingsRepository; _authorizationService = authorizationService; _CssThemeManager = cssThemeManager; _appService = appService; + _webHostEnvironment = webHostEnvironment; _EventAggregator = eventAggregator; _NetworkProvider = networkProvider; _ExplorerProvider = explorerProvider; - _FeeRateProvider = feeRateProvider; _ServiceProvider = serviceProvider; _BtcpayServerOptions = btcpayServerOptions; _BTCPayEnv = btcpayEnv; @@ -92,20 +91,18 @@ namespace BTCPayServer.Controllers readonly IServiceProvider _ServiceProvider; readonly BTCPayNetworkProvider _NetworkProvider; private readonly ExplorerClientProvider _ExplorerProvider; - private readonly IFeeProviderFactory _FeeRateProvider; readonly BTCPayWalletProvider _WalletProvider; readonly AccessTokenController _TokenController; readonly StoreRepository _Repo; readonly TokenRepository _TokenRepository; readonly UserManager _UserManager; private readonly LanguageService _LangService; - readonly IWebHostEnvironment _Env; - private readonly IHttpClientFactory _httpClientFactory; private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary; private readonly SettingsRepository _settingsRepository; private readonly IAuthorizationService _authorizationService; private readonly CssThemeManager _CssThemeManager; private readonly AppService _appService; + private readonly IWebHostEnvironment _webHostEnvironment; private readonly EventAggregator _EventAggregator; [TempData] @@ -964,5 +961,8 @@ namespace BTCPayServer.Controllers }); } + + + } } diff --git a/BTCPayServer/Data/StoreBlob.cs b/BTCPayServer/Data/StoreBlob.cs index 7149a2c71..e0e8ffc1a 100644 --- a/BTCPayServer/Data/StoreBlob.cs +++ b/BTCPayServer/Data/StoreBlob.cs @@ -11,6 +11,7 @@ using BTCPayServer.Payments.CoinSwitch; using BTCPayServer.Rating; using BTCPayServer.Services.Mails; using BTCPayServer.Services.Rates; +using BTCPayServer.Services.Shopify.Models; using Newtonsoft.Json; namespace BTCPayServer.Data @@ -26,6 +27,8 @@ namespace BTCPayServer.Data RecommendedFeeBlockTarget = 1; } + public ShopifySettings Shopify { get; set; } + [Obsolete("Use NetworkFeeMode instead")] [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] public bool? NetworkFeeDisabled diff --git a/BTCPayServer/HostedServices/InvoiceNotificationManager.cs b/BTCPayServer/HostedServices/InvoiceNotificationManager.cs index 37d3048c1..eb8fefee4 100644 --- a/BTCPayServer/HostedServices/InvoiceNotificationManager.cs +++ b/BTCPayServer/HostedServices/InvoiceNotificationManager.cs @@ -331,17 +331,17 @@ namespace BTCPayServer.HostedServices e.Name == InvoiceEvent.Completed || e.Name == InvoiceEvent.ExpiredPaidPartial ) - Notify(invoice, e, false); + _ = Notify(invoice, e, false); } if (e.Name == InvoiceEvent.Confirmed) { - Notify(invoice, e, false); + _ = Notify(invoice, e, false); } if (invoice.ExtendedNotifications) { - Notify(invoice, e, true); + _ = Notify(invoice, e, true); } })); diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index ef5e32576..0dbb31fe0 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -25,6 +25,7 @@ using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications.Blobs; using BTCPayServer.Services.PaymentRequests; using BTCPayServer.Services.Rates; +using BTCPayServer.Services.Shopify; using BTCPayServer.Services.Stores; using BTCPayServer.Services.Wallets; using BTCPayServer.U2F; @@ -245,7 +246,8 @@ namespace BTCPayServer.Hosting services.AddSingleton(); services.AddSingleton(); - + + services.AddShopify(); #if DEBUG services.AddSingleton(); #endif @@ -295,12 +297,14 @@ namespace BTCPayServer.Hosting rateLimits.SetZone($"zone={ZoneLimits.Login} rate=1000r/min burst=100 nodelay"); rateLimits.SetZone($"zone={ZoneLimits.Register} rate=1000r/min burst=100 nodelay"); rateLimits.SetZone($"zone={ZoneLimits.PayJoin} rate=1000r/min burst=100 nodelay"); + rateLimits.SetZone($"zone={ZoneLimits.Shopify} rate=1000r/min burst=100 nodelay"); } else { rateLimits.SetZone($"zone={ZoneLimits.Login} rate=5r/min burst=3 nodelay"); rateLimits.SetZone($"zone={ZoneLimits.Register} rate=2r/min burst=2 nodelay"); rateLimits.SetZone($"zone={ZoneLimits.PayJoin} rate=5r/min burst=3 nodelay"); + rateLimits.SetZone($"zone={ZoneLimits.Shopify} rate=20r/min burst=3 nodelay"); } return rateLimits; }); diff --git a/BTCPayServer/Models/StoreViewModels/IntegrationsViewModel.cs b/BTCPayServer/Models/StoreViewModels/IntegrationsViewModel.cs new file mode 100644 index 000000000..d5b6d3b89 --- /dev/null +++ b/BTCPayServer/Models/StoreViewModels/IntegrationsViewModel.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using BTCPayServer.Services.Shopify.Models; +using static BTCPayServer.Data.StoreBlob; + +namespace BTCPayServer.Models.StoreViewModels +{ + public class IntegrationsViewModel + { + public ShopifySettings Shopify { get; set; } + } +} diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index aff8665a4..cd80d7d31 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -259,11 +259,11 @@ namespace BTCPayServer.Services.Invoices [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] public HashSet InternalTags { get; set; } = new HashSet(); - public string[] GetInternalTags(string suffix) + public string[] GetInternalTags(string prefix) { return InternalTags == null ? Array.Empty() : InternalTags - .Where(t => t.StartsWith(suffix, StringComparison.InvariantCulture)) - .Select(t => t.Substring(suffix.Length)).ToArray(); + .Where(t => t.StartsWith(prefix, StringComparison.InvariantCulture)) + .Select(t => t.Substring(prefix.Length)).ToArray(); } [Obsolete("Use GetDerivationStrategies instead")] diff --git a/BTCPayServer/Services/Shopify/ApiModels/CreateScriptResponse.cs b/BTCPayServer/Services/Shopify/ApiModels/CreateScriptResponse.cs new file mode 100644 index 000000000..d5560abd5 --- /dev/null +++ b/BTCPayServer/Services/Shopify/ApiModels/CreateScriptResponse.cs @@ -0,0 +1,31 @@ +using System; +using Newtonsoft.Json; + +namespace BTCPayServer.Services.Shopify.ApiModels +{ + public class CreateScriptResponse + { + [JsonProperty("script_tag")] + public ScriptTag ScriptTag { get; set; } + } + + public class ScriptTag { + [JsonProperty("id")] + public int Id { get; set; } + + [JsonProperty("src")] + public string Src { get; set; } + + [JsonProperty("event")] + public string Event { get; set; } + + [JsonProperty("created_at")] + public DateTime CreatedAt { get; set; } + + [JsonProperty("updated_at")] + public DateTime UpdatedAt { get; set; } + + [JsonProperty("display_scope")] + public string DisplayScope { get; set; } + } +} diff --git a/BTCPayServer/Services/Shopify/ApiModels/CreateWebhookResponse.cs b/BTCPayServer/Services/Shopify/ApiModels/CreateWebhookResponse.cs new file mode 100644 index 000000000..d4b7cc6e6 --- /dev/null +++ b/BTCPayServer/Services/Shopify/ApiModels/CreateWebhookResponse.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace BTCPayServer.Services.Shopify.ApiModels +{ + public class CreateWebhookResponse + { + [JsonProperty("webhook")] public Webhook Webhook { get; set; } + } + + public class Webhook + { + [JsonProperty("id")] public int Id { get; set; } + + [JsonProperty("address")] public string Address { get; set; } + + [JsonProperty("topic")] public string Topic { get; set; } + + [JsonProperty("created_at")] public DateTime CreatedAt { get; set; } + + [JsonProperty("updated_at")] public DateTime UpdatedAt { get; set; } + + [JsonProperty("format")] public string Format { get; set; } + + [JsonProperty("fields")] public List Fields { get; set; } + + [JsonProperty("metafield_namespaces")] public List MetafieldNamespaces { get; set; } + + [JsonProperty("api_version")] public string ApiVersion { get; set; } + + [JsonProperty("private_metafield_namespaces")] + public List PrivateMetafieldNamespaces { get; set; } + } +} diff --git a/BTCPayServer/Services/Shopify/ApiModels/DataHolders/TransactionDataHolder.cs b/BTCPayServer/Services/Shopify/ApiModels/DataHolders/TransactionDataHolder.cs new file mode 100644 index 000000000..136b4949d --- /dev/null +++ b/BTCPayServer/Services/Shopify/ApiModels/DataHolders/TransactionDataHolder.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BTCPayServer.Services.Shopify.ApiModels.DataHolders +{ + public class TransactionDataHolder + { + public long id { get; set; } + public long? order_id { get; set; } + public string kind { get; set; } + public string gateway { get; set; } + public string status { get; set; } + public string message { get; set; } + public DateTimeOffset created_at { get; set; } + public bool test { get; set; } + public string authorization { get; set; } + public string location_id { get; set; } + public string user_id { get; set; } + public long? parent_id { get; set; } + public DateTimeOffset processed_at { get; set; } + public string device_id { get; set; } + public object receipt { get; set; } + public string error_code { get; set; } + public string source_name { get; set; } + public string currency_exchange_adjustment { get; set; } + public string amount { get; set; } + public string currency { get; set; } + public string admin_graphql_api_id { get; set; } + } +} diff --git a/BTCPayServer/Services/Shopify/ApiModels/OrdersCountResp.cs b/BTCPayServer/Services/Shopify/ApiModels/OrdersCountResp.cs new file mode 100644 index 000000000..7aef830b9 --- /dev/null +++ b/BTCPayServer/Services/Shopify/ApiModels/OrdersCountResp.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Services.Shopify.ApiModels +{ + public class CountResponse + { + [JsonProperty("count")] + public long Count { get; set; } + } +} diff --git a/BTCPayServer/Services/Shopify/ApiModels/ShopifyOrder.cs b/BTCPayServer/Services/Shopify/ApiModels/ShopifyOrder.cs new file mode 100644 index 000000000..1b7e513fe --- /dev/null +++ b/BTCPayServer/Services/Shopify/ApiModels/ShopifyOrder.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace BTCPayServer.Services.Shopify.ApiModels +{ + public class ShopifyOrder + { + [JsonProperty("id")] + public string Id { get; set; } + [JsonProperty("total_price")] + public decimal TotalPrice { get; set; } + [JsonProperty("currency")] + public string Currency { get; set; } + [JsonProperty("financial_status")] + public string FinancialStatus { get; set; } + [JsonProperty("transactions")] + public IEnumerable Transactions { get; set; } + } +} diff --git a/BTCPayServer/Services/Shopify/ApiModels/ShopifyTransaction.cs b/BTCPayServer/Services/Shopify/ApiModels/ShopifyTransaction.cs new file mode 100644 index 000000000..80ed1842c --- /dev/null +++ b/BTCPayServer/Services/Shopify/ApiModels/ShopifyTransaction.cs @@ -0,0 +1,48 @@ +using System; +using Newtonsoft.Json; + +namespace BTCPayServer.Services.Shopify.ApiModels +{ + public class ShopifyTransaction + { + [JsonProperty("amount")] + public decimal? Amount { get; set; } + + [JsonProperty("authorization")] + public string Authorization { get; set; } + + [JsonProperty("created_at")] + public DateTimeOffset? CreatedAt { get; set; } + + [JsonProperty("device_id")] + public string DeviceId { get; set; } + + [JsonProperty("gateway")] + public string Gateway { get; set; } + [JsonProperty("kind")] + public string Kind { get; set; } + [JsonProperty("order_id")] + public long? OrderId { get; set; } + + /// + /// A standardized error code, e.g. 'incorrect_number', independent of the payment provider. Value can be null. A full list of known values can be found at https://help.shopify.com/api/reference/transaction. + /// + [JsonProperty("error_code")] + public string ErrorCode { get; set; } + + /// + /// The status of the transaction. Valid values are: pending, failure, success or error. + /// + [JsonProperty("status")] + public string Status { get; set; } + [JsonProperty("test")] + public bool? Test { get; set; } + [JsonProperty("currency")] + public string Currency { get; set; } + /// + /// This property is undocumented by Shopify. + /// + [JsonProperty("parent_id")] + public long? ParentId { get; set; } + } +} diff --git a/BTCPayServer/Services/Shopify/ApiModels/TransactionsCreateReq.cs b/BTCPayServer/Services/Shopify/ApiModels/TransactionsCreateReq.cs new file mode 100644 index 000000000..90df0d1e5 --- /dev/null +++ b/BTCPayServer/Services/Shopify/ApiModels/TransactionsCreateReq.cs @@ -0,0 +1,19 @@ +namespace BTCPayServer.Services.Shopify.ApiModels +{ + public class TransactionsCreateReq + { + public DataHolder transaction { get; set; } + + public class DataHolder + { + public string currency { get; set; } + public string amount { get; set; } + public string kind { get; set; } + public long? parent_id { get; set; } + public string gateway { get; set; } + public string source { get; set; } + public string status { get; set; } + public string authorization { get; set; } + } + } +} diff --git a/BTCPayServer/Services/Shopify/ApiModels/TransactionsCreateResp.cs b/BTCPayServer/Services/Shopify/ApiModels/TransactionsCreateResp.cs new file mode 100644 index 000000000..ae38f3f60 --- /dev/null +++ b/BTCPayServer/Services/Shopify/ApiModels/TransactionsCreateResp.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using BTCPayServer.Services.Shopify.ApiModels.DataHolders; + +namespace BTCPayServer.Services.Shopify.ApiModels +{ + public class TransactionsCreateResp + { + public TransactionDataHolder transaction { get; set; } + } +} diff --git a/BTCPayServer/Services/Shopify/ApiModels/TransactionsListResp.cs b/BTCPayServer/Services/Shopify/ApiModels/TransactionsListResp.cs new file mode 100644 index 000000000..595d84f5f --- /dev/null +++ b/BTCPayServer/Services/Shopify/ApiModels/TransactionsListResp.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using BTCPayServer.Services.Shopify.ApiModels.DataHolders; + +namespace BTCPayServer.Services.Shopify.ApiModels +{ + public class TransactionsListResp + { + public List transactions { get; set; } + } +} diff --git a/BTCPayServer/Services/Shopify/Models/ShopifySettings.cs b/BTCPayServer/Services/Shopify/Models/ShopifySettings.cs new file mode 100644 index 000000000..f138a26f1 --- /dev/null +++ b/BTCPayServer/Services/Shopify/Models/ShopifySettings.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace BTCPayServer.Services.Shopify.Models +{ + public class ShopifySettings + { + [Display(Name = "Shop Name")] + public string ShopName { get; set; } + public string ApiKey { get; set; } + public string Password { get; set; } + + public bool CredentialsPopulated() + { + return + !string.IsNullOrWhiteSpace(ShopName) && + !string.IsNullOrWhiteSpace(ApiKey) && + !string.IsNullOrWhiteSpace(Password); + } + public DateTimeOffset? IntegratedAt { get; set; } + } +} diff --git a/BTCPayServer/Services/Shopify/OrderTransactionRegisterLogic.cs b/BTCPayServer/Services/Shopify/OrderTransactionRegisterLogic.cs new file mode 100644 index 000000000..1465bf071 --- /dev/null +++ b/BTCPayServer/Services/Shopify/OrderTransactionRegisterLogic.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Services.Shopify.ApiModels; +using BTCPayServer.Services.Shopify.ApiModels.DataHolders; + +namespace BTCPayServer.Services.Shopify +{ + public class OrderTransactionRegisterLogic + { + private readonly ShopifyApiClient _client; + + public OrderTransactionRegisterLogic(ShopifyApiClient client) + { + _client = client; + } + + private static string[] _keywords = new[] { "bitcoin", "btc", "btcpayserver", "btcpay server" }; + public async Task Process(string orderId, string invoiceId, string currency, string amountCaptured, bool success) + { + currency = currency.ToUpperInvariant().Trim(); + var existingShopifyOrderTransactions = (await _client.TransactionsList(orderId)).transactions; + var baseParentTransaction = existingShopifyOrderTransactions.FirstOrDefault(); + if (baseParentTransaction is null || + !_keywords.Any(a => baseParentTransaction.gateway.Contains(a, StringComparison.InvariantCultureIgnoreCase))) + { + return null; + } + + //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 (currency.ToUpperInvariant().Trim() != baseParentTransaction.currency.ToUpperInvariant().Trim()) + { + // 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 + return null; + } + + var kind = "capture"; + var parentId = baseParentTransaction.id; + var status = success ? "success" : "failure"; + //find all existing transactions recorded around this invoice id + var existingShopifyOrderTransactionsOnSameInvoice = + existingShopifyOrderTransactions.Where(holder => holder.authorization == invoiceId); + + //filter out the successful ones + var successfulActions = + existingShopifyOrderTransactionsOnSameInvoice.Where(holder => holder.status == "success").ToArray(); + + //of the successful ones, get the ones we registered as a valid payment + var successfulCaptures = successfulActions.Where(holder => holder.kind == "capture").ToArray(); + + //of the successful ones, get the ones we registered as a voiding of a previous successful payment + var refunds = successfulActions.Where(holder => holder.kind == "refund").ToArray(); + + //if we are working with a non-success registration, but see that we have previously registered this invoice as a success, we switch to creating a "void" transaction, which in shopify terms is a refund. + if (!success && successfulCaptures.Length > 0 && (successfulCaptures.Length - refunds.Length) > 0) + { + kind = "void"; + parentId = successfulCaptures.Last().id; + status = "success"; + } + //if we are working with a success registration, but can see that we have already had a successful transaction saved, get outta here + else if (success && successfulCaptures.Length > 0 && (successfulCaptures.Length - refunds.Length) > 0) + { + return null; + } + var createTransaction = new TransactionsCreateReq + { + transaction = new TransactionsCreateReq.DataHolder + { + parent_id = parentId, + currency = currency, + amount = amountCaptured, + kind = kind, + gateway = "BTCPayServer", + source = "external", + authorization = invoiceId, + status = status + } + }; + var createResp = await _client.TransactionCreate(orderId, createTransaction); + return createResp; + } + } +} diff --git a/BTCPayServer/Services/Shopify/ShopifyApiClient.cs b/BTCPayServer/Services/Shopify/ShopifyApiClient.cs new file mode 100644 index 000000000..4d1accd7d --- /dev/null +++ b/BTCPayServer/Services/Shopify/ShopifyApiClient.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using BTCPayServer.Services.Shopify.ApiModels; +using DBriize.Utils; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Services.Shopify +{ + public class ShopifyApiClient + { + private readonly HttpClient _httpClient; + private readonly ShopifyApiClientCredentials _credentials; + + public ShopifyApiClient(IHttpClientFactory httpClientFactory, ShopifyApiClientCredentials credentials) + { + if (httpClientFactory != null) + { + _httpClient = httpClientFactory.CreateClient(nameof(ShopifyApiClient)); + } + else // tests don't provide IHttpClientFactory + { + _httpClient = new HttpClient(); + } + + _credentials = credentials; + + var bearer = $"{_credentials.ApiKey}:{_credentials.ApiPassword}"; + bearer = Encoding.UTF8.GetBytes(bearer).ToBase64String(); + + _httpClient.DefaultRequestHeaders.Add("Authorization", "Basic " + bearer); + } + + private HttpRequestMessage CreateRequest(string shopName, HttpMethod method, string action, + string relativeUrl = null) + { + var url = + $"https://{(shopName.Contains(".", StringComparison.InvariantCulture) ? shopName : $"{shopName}.myshopify.com")}/{relativeUrl ?? ("admin/api/2020-07/" + action)}"; + var req = new HttpRequestMessage(method, url); + return req; + } + + private async Task SendRequest(HttpRequestMessage req) + { + using var resp = await _httpClient.SendAsync(req); + + var strResp = await resp.Content.ReadAsStringAsync(); + if (strResp?.StartsWith("{\"errors\":\"[API] Invalid API key or access token", StringComparison.OrdinalIgnoreCase) == true) + throw new ShopifyApiException("Invalid API key or access token"); + + return strResp; + } + + public async Task CreateWebhook(string topic, string address, string format = "json") + { + var req = CreateRequest(_credentials.ShopName, HttpMethod.Post, $"webhooks.json"); + req.Content = new StringContent(JsonConvert.SerializeObject(new {topic, address, format}), Encoding.UTF8, + "application/json"); + var strResp = await SendRequest(req); + + return JsonConvert.DeserializeObject(strResp); + } + + public async Task RemoveWebhook(string id) + { + var req = CreateRequest(_credentials.ShopName, HttpMethod.Delete, $"webhooks/{id}.json"); + var strResp = await SendRequest(req); + } + + public async Task CheckScopes() + { + var req = CreateRequest(_credentials.ShopName, HttpMethod.Get, null, "admin/oauth/access_scopes.json"); + return JObject.Parse(await SendRequest(req))["access_scopes"].Values() + .Select(token => token["handle"].Value()).ToArray(); + } + + public async Task TransactionsList(string orderId) + { + var req = CreateRequest(_credentials.ShopName, HttpMethod.Get, $"orders/{orderId}/transactions.json"); + + var strResp = await SendRequest(req); + + var parsed = JsonConvert.DeserializeObject(strResp); + + return parsed; + } + + public async Task TransactionCreate(string orderId, TransactionsCreateReq txnCreate) + { + var postJson = JsonConvert.SerializeObject(txnCreate); + + var req = CreateRequest(_credentials.ShopName, HttpMethod.Post, $"orders/{orderId}/transactions.json"); + req.Content = new StringContent(postJson, Encoding.UTF8, "application/json"); + + var strResp = await SendRequest(req); + return JsonConvert.DeserializeObject(strResp); + } + + public async Task GetOrder(string orderId) + { + var req = CreateRequest(_credentials.ShopName, HttpMethod.Get, + $"orders/{orderId}.json?fields=id,total_price,currency,transactions,financial_status"); + + var strResp = await SendRequest(req); + + return JObject.Parse(strResp)["order"].ToObject(); + } + + public async Task OrdersCount() + { + var req = CreateRequest(_credentials.ShopName, HttpMethod.Get, $"orders/count.json"); + var strResp = await SendRequest(req); + + var parsed = JsonConvert.DeserializeObject(strResp); + + return parsed.Count; + } + + public async Task OrderExists(string orderId) + { + var req = CreateRequest(_credentials.ShopName, HttpMethod.Get, $"orders/{orderId}.json?fields=id"); + var strResp = await SendRequest(req); + + return strResp?.Contains(orderId, StringComparison.OrdinalIgnoreCase) == true; + } + } +} + +public class ShopifyApiClientCredentials +{ + public string ShopName { get; set; } + public string ApiKey { get; set; } + public string ApiPassword { get; set; } +} diff --git a/BTCPayServer/Services/Shopify/ShopifyApiException.cs b/BTCPayServer/Services/Shopify/ShopifyApiException.cs new file mode 100644 index 000000000..28ca4c3ee --- /dev/null +++ b/BTCPayServer/Services/Shopify/ShopifyApiException.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BTCPayServer.Services.Shopify +{ + public class ShopifyApiException : Exception + { + public ShopifyApiException(string message) : base(message) + { + } + } +} diff --git a/BTCPayServer/Services/Shopify/ShopifyExtensions.cs b/BTCPayServer/Services/Shopify/ShopifyExtensions.cs new file mode 100644 index 000000000..a92aa524c --- /dev/null +++ b/BTCPayServer/Services/Shopify/ShopifyExtensions.cs @@ -0,0 +1,24 @@ +using BTCPayServer.Services.Shopify.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace BTCPayServer.Services.Shopify +{ + public static class ShopifyExtensions + { + public static ShopifyApiClientCredentials CreateShopifyApiCredentials(this ShopifySettings shopify) + { + return new ShopifyApiClientCredentials + { + ShopName = shopify.ShopName, + ApiKey = shopify.ApiKey, + ApiPassword = shopify.Password + }; + } + + public static void AddShopify(this IServiceCollection services) + { + services.AddSingleton(); + } + } +} diff --git a/BTCPayServer/Services/Shopify/ShopifyOrderMarkerHostedService.cs b/BTCPayServer/Services/Shopify/ShopifyOrderMarkerHostedService.cs new file mode 100644 index 000000000..4f2ccca2c --- /dev/null +++ b/BTCPayServer/Services/Shopify/ShopifyOrderMarkerHostedService.cs @@ -0,0 +1,114 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using BTCPayServer.Data; +using BTCPayServer.Events; +using BTCPayServer.HostedServices; +using BTCPayServer.Logging; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Shopify.Models; +using BTCPayServer.Services.Stores; +using Microsoft.Extensions.Logging; + +namespace BTCPayServer.Services.Shopify +{ + public class ShopifyOrderMarkerHostedService : EventHostedServiceBase + { + private readonly StoreRepository _storeRepository; + private readonly IHttpClientFactory _httpClientFactory; + + public ShopifyOrderMarkerHostedService(EventAggregator eventAggregator, + StoreRepository storeRepository, + IHttpClientFactory httpClientFactory) : base(eventAggregator) + { + _storeRepository = storeRepository; + _httpClientFactory = httpClientFactory; + } + + public const string SHOPIFY_ORDER_ID_PREFIX = "shopify-"; + + protected override void SubscribeToEvents() + { + Subscribe(); + base.SubscribeToEvents(); + } + + protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) + { + if (evt is InvoiceEvent invoiceEvent && !new[] + { + InvoiceEvent.Created, InvoiceEvent.Confirmed, InvoiceEvent.ExpiredPaidPartial, + InvoiceEvent.ReceivedPayment, InvoiceEvent.PaidInFull + }.Contains(invoiceEvent.Name)) + { + var invoice = invoiceEvent.Invoice; + var shopifyOrderId = invoice.GetInternalTags(SHOPIFY_ORDER_ID_PREFIX).FirstOrDefault(); + if (shopifyOrderId != null) + { + if (new[] {InvoiceStatus.Invalid, InvoiceStatus.Expired}.Contains(invoice.GetInvoiceState() + .Status) && invoice.ExceptionStatus != InvoiceExceptionStatus.None) + { + //you have failed us, customer + + await RegisterTransaction(invoice, shopifyOrderId, false); + } + else if (new[] {InvoiceStatus.Complete, InvoiceStatus.Confirmed}.Contains( + invoice.Status)) + { + await RegisterTransaction(invoice, shopifyOrderId, true); + } + } + } + + await base.ProcessEvent(evt, cancellationToken); + } + + private async Task RegisterTransaction(InvoiceEntity invoice, string shopifyOrderId, bool success) + { + var storeData = await _storeRepository.FindStore(invoice.StoreId); + var storeBlob = storeData.GetStoreBlob(); + + // ensure that store in question has shopify integration turned on + // and that invoice's orderId has shopify specific prefix + if (storeBlob.Shopify?.IntegratedAt.HasValue == true) + { + var client = CreateShopifyApiClient(storeBlob.Shopify); + if (!await client.OrderExists(shopifyOrderId)) + { + // don't register transactions for orders that don't exist on shopify + return; + } + + // if we got this far, we likely need to register this invoice's payment on Shopify + // OrderTransactionRegisterLogic has check if transaction is already registered which is why we're passing invoice.Id + try + { + var logic = new OrderTransactionRegisterLogic(client); + var resp = await logic.Process(shopifyOrderId, invoice.Id, invoice.Currency, + invoice.Price.ToString(CultureInfo.InvariantCulture), success); + if (resp != null) + { + Logs.PayServer.LogInformation($"Registered order transaction {invoice.Price}{invoice.Currency} on Shopify. " + + $"Triggered by invoiceId: {invoice.Id}, Shopify orderId: {shopifyOrderId}, Success: {success}"); + } + } + catch (Exception ex) + { + Logs.PayServer.LogError(ex, + $"Shopify error while trying to register order transaction. " + + $"Triggered by invoiceId: {invoice.Id}, Shopify orderId: {shopifyOrderId}"); + } + } + } + + + private ShopifyApiClient CreateShopifyApiClient(ShopifySettings shopify) + { + return new ShopifyApiClient(_httpClientFactory, shopify.CreateShopifyApiCredentials()); + } + } +} diff --git a/BTCPayServer/Views/Stores/Integrations.cshtml b/BTCPayServer/Views/Stores/Integrations.cshtml new file mode 100644 index 000000000..769fbc365 --- /dev/null +++ b/BTCPayServer/Views/Stores/Integrations.cshtml @@ -0,0 +1,39 @@ +@model IntegrationsViewModel +@{ + Layout = "../Shared/_NavLayout.cshtml"; + ViewData.SetActivePageAndTitle(StoreNavPages.Integrations, "Integrations"); +} + + + +@if (!ViewContext.ModelState.IsValid) +{ +
+
+
+
+
+} + +
+
+ + +

+ Other Integrations +

+

+ Take a look at documentation for the list of other integrations we support and the directions on how to enable them: +

+

+
+
+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/BTCPayServer/Views/Stores/Integrations/Shopify.cshtml b/BTCPayServer/Views/Stores/Integrations/Shopify.cshtml new file mode 100644 index 000000000..a45b4047a --- /dev/null +++ b/BTCPayServer/Views/Stores/Integrations/Shopify.cshtml @@ -0,0 +1,81 @@ +@model IntegrationsViewModel +@{ + var shopify = Model.Shopify; + var shopifyCredsSet = shopify?.IntegratedAt.HasValue is true; + var shopifyUrl = shopify is null? null: !shopify?.ShopName?.Contains(".") is true ? $"https://{shopify.ShopName}.myshopify.com" : shopify.ShopName; +} +
+

+ Shopify + + + +

+ @if (!shopifyCredsSet) + { +

Create a Shopify Private App with the permissions "Orders - Read and write"

+ +
+ + +
+ + + } + else + { +
+ +
+ @if (!Model.Shopify?.ShopName?.Contains(".") is true) + { +
+ https:// +
+ } + + + @if (!Model.Shopify?.ShopName?.Contains(".") is true) + { +
+ .myshopify.com +
+ } +
+ +
+ +
+ + + +
+ +
+ + + +
+
+

+ In Shopify please paste following script at Settings > Checkout > Order Processing > Additional Scripts +

+ + @($"") + +
+

+ In Shopify please add a payment method at Settings > Payments > Manual Payment Methods with the name Bitcoin with BTCPay Server +

+ +

+ Orders on @shopify.ShopName.myshopify.com will be marked as paid on successful invoice payment. + Started: @shopify.IntegratedAt.Value.ToBrowserDate() +

+ + + } + +
diff --git a/BTCPayServer/Views/Stores/StoreNavPages.cs b/BTCPayServer/Views/Stores/StoreNavPages.cs index ab8060240..13f1873f8 100644 --- a/BTCPayServer/Views/Stores/StoreNavPages.cs +++ b/BTCPayServer/Views/Stores/StoreNavPages.cs @@ -2,6 +2,6 @@ namespace BTCPayServer.Views.Stores { public enum StoreNavPages { - ActivePage, Index, Rates, Checkout, Tokens, Users, PayButton + ActivePage, Index, Rates, Checkout, Tokens, Users, PayButton, Integrations } } diff --git a/BTCPayServer/Views/Stores/_Nav.cshtml b/BTCPayServer/Views/Stores/_Nav.cshtml index 2a3fd3755..47d4c3090 100644 --- a/BTCPayServer/Views/Stores/_Nav.cshtml +++ b/BTCPayServer/Views/Stores/_Nav.cshtml @@ -1,14 +1,15 @@ diff --git a/BTCPayServer/ZoneLimits.cs b/BTCPayServer/ZoneLimits.cs index 69edb48aa..dc8feb637 100644 --- a/BTCPayServer/ZoneLimits.cs +++ b/BTCPayServer/ZoneLimits.cs @@ -5,5 +5,6 @@ namespace BTCPayServer public const string Login = "btcpaylogin"; public const string Register = "btcpayregister"; public const string PayJoin = "PayJoin"; + public const string Shopify = nameof(Shopify); } } diff --git a/BTCPayServer/bundleconfig.json b/BTCPayServer/bundleconfig.json index a87a91893..31e41a3fe 100644 --- a/BTCPayServer/bundleconfig.json +++ b/BTCPayServer/bundleconfig.json @@ -205,5 +205,12 @@ "minify": { "enabled": false } - } + }, + { + "outputFileName": "wwwroot/bundles/shopify-bundle.min.js", + "inputFiles": [ + "wwwroot/modal/btcpay.js", + "wwwroot/shopify/btcpay-shopify.js" + ] + } ] diff --git a/BTCPayServer/wwwroot/modal/btcpay.js b/BTCPayServer/wwwroot/modal/btcpay.js index 15a972b81..14a64d69f 100644 --- a/BTCPayServer/wwwroot/modal/btcpay.js +++ b/BTCPayServer/wwwroot/modal/btcpay.js @@ -40,7 +40,7 @@ iframe.style.width = '100%'; iframe.style.zIndex = '2000'; - var origin = 'http://slack.btcpayserver.org join us there, and initialize this with your origin url through setApiUrlPrefix'; + var origin = 'http://chat.btcpayserver.org join us there, and initialize this with your origin url through setApiUrlPrefix'; var scriptMatch = thisScript.match(scriptSrcRegex) if (scriptMatch) { // We can't just take the domain as btcpay can run under a sub path with RootPath @@ -56,6 +56,7 @@ var onModalWillEnterMethod = function () { }; var onModalWillLeaveMethod = function () { }; + var onModalReceiveMessageMethod = function (event) { }; function showFrame() { if (window.document.getElementsByName('btcpay').length === 0) { @@ -80,6 +81,10 @@ onModalWillLeaveMethod = customOnModalWillLeave; } + function onModalReceiveMessage(customOnModalReceiveMessage) { + onModalReceiveMessageMethod = customOnModalReceiveMessage; + } + function receiveMessage(event) { var uri; @@ -101,6 +106,7 @@ window.location = uri; } } + onModalReceiveMessageMethod(event); } function showInvoice(invoiceId, params) { @@ -134,7 +140,8 @@ showInvoice: showInvoice, onModalWillEnter: onModalWillEnter, onModalWillLeave: onModalWillLeave, - setApiUrlPrefix: setApiUrlPrefix + setApiUrlPrefix: setApiUrlPrefix, + onModalReceiveMessage: onModalReceiveMessage }; })(); diff --git a/BTCPayServer/wwwroot/shopify/btcpay-browser-client.js b/BTCPayServer/wwwroot/shopify/btcpay-browser-client.js deleted file mode 100644 index d73aa9804..000000000 --- a/BTCPayServer/wwwroot/shopify/btcpay-browser-client.js +++ /dev/null @@ -1,88 +0,0 @@ -/* Based on @djseeds script: https://github.com/btcpayserver/btcpayserver/issues/36#issuecomment-633109155 */ -/*** Creates a new BTCPayServer Modal - * @param url - BTCPayServer Base URL - * @param storeId - BTCPayServer store ID - * @param data - Data to use for invoice creation -* @returns - A promise that resolves when invoice is paid. -* ***/ -var BtcPayServerModal = (function () { - function waitForPayment(btcPayServerUrl, invoiceId, storeId) { - // Todo: mutex lock on btcpayserver modal. - return new Promise(function (resolve, reject) { - // Don't allow two modals at once. - if (waitForPayment.lock) { - resolve(null); - } - else { - waitForPayment.lock = true; - } - window.btcpay.onModalWillEnter(function () { - var interval = setInterval(function () { - getBtcPayInvoice(btcPayServerUrl, invoiceId, storeId) - .then(function (invoice) { - if (invoice.status == "complete") { - clearInterval(interval); - resolve(invoice); - } - }) - .catch(function (err) { - clearInterval(interval); - reject(err); - }); - }, 1000); - window.btcpay.onModalWillLeave(function () { - waitForPayment.lock = false; - clearInterval(interval); - // If user exited the payment modal, - // indicate that there was no error but invoice did not complete. - resolve(null); - }); - }); - window.btcpay.showInvoice(invoiceId); - }); - } - - function getBtcPayInvoice(btcPayServerUrl, invoiceId, storeId) { - const url = btcPayServerUrl + "/invoices/" + invoiceId + "?storeId=" + storeId; - return fetch(url, { - method: "GET", - mode: "cors", // no-cors, cors, *same-origin, - headers: { - "Content-Type": "application/json", - "accept": "application/json", - } - }) - .then(function (response) { - return response.json(); - }) - .then(function (json) { - return json.data; - }) - } - - return { - show: function (url, storeId, data) { - const path = url + "/invoices?storeId=" + storeId; - return fetch(path, - { - method: "POST", - mode: "cors", - headers: { - "Content-Type": "application/json", - "accept": "application/json", - }, - body: JSON.stringify(data) - } - ) - .then(function (response) { - return response.json(); - }) - .then(function (response) { - return waitForPayment(url, response.data.id, storeId); - }); - }, - hide: function () { - window.btcpay.hideFrame(); - } - } -})() diff --git a/BTCPayServer/wwwroot/shopify/btcpay-shopify-checkout.js b/BTCPayServer/wwwroot/shopify/btcpay-shopify-checkout.js deleted file mode 100644 index 54203f898..000000000 --- a/BTCPayServer/wwwroot/shopify/btcpay-shopify-checkout.js +++ /dev/null @@ -1,138 +0,0 @@ -/* Based on @djseeds script: https://github.com/btcpayserver/btcpayserver/issues/36#issuecomment-633109155 */ - -/* - -1. In your BTCPayServer store you need to check "Allow anyone to create invoice" -2. In Shopify Settings > Payment Providers > Manual Payment Methods add one which contains "Bitcoin with BTCPayServer" -3. In Shopify Settings > Checkout > Additional Scripts input the following script, with the details from your BTCPayServer instead of the placeholder values. - - - - - - */ - -! function () { - // extracted from shopify initialized page - const shopify_price = Shopify.checkout.payment_due; - const shopify_currency = Shopify.checkout.currency; - - "use strict"; - const pageElements = document.querySelector.bind(document), - insertElement = (document.querySelectorAll.bind(document), - (e, - n) => { - n.parentNode.insertBefore(e, - n.nextSibling) - }); - - let pageItems = {}, - pageheader = "Thank you!", - buttonElement = null; - - const setPageItems = () => { - pageItems = { - mainHeader: pageElements("#main-header"), - orderConfirmed: pageElements(".os-step__title"), - orderConfirmedDescription: pageElements(".os-step__description"), - continueButton: pageElements(".step__footer__continue-btn"), - checkMarkIcon: pageElements(".os-header__hanging-icon"), - orderStatus: pageElements(".os-header__title"), - paymentMethod: pageElements(".payment-method-list__item__info"), - price: pageElements(".payment-due__price"), - finalPrice: pageElements(".total-recap__final-price"), - orderNumber: pageElements(".os-order-number"), - } - } - - const orderPaid = () => { - pageItems.mainHeader.innerText = pageheader, - pageItems.orderConfirmed && (pageItems.orderConfirmed.style.display = "block"), - pageItems.orderConfirmedDescription && (pageItems.orderConfirmedDescription.style.display = "block"), - pageItems.continueButton && (pageItems.continueButton.style.visibility = "visible"), - pageItems.checkMarkIcon && (pageItems.checkMarkIcon.style.visibility = "visible"), - buttonElement && (buttonElement.style.display = "none"); - }; - - window.setOrderAsPaid = orderPaid, - window.openBtcPayShopify = function waitForPaymentMethod() { - if (setPageItems(), "Order canceled" === pageItems.orderStatus.innerText) { - return; - } - - const paymentMethod = pageItems.paymentMethod; - - if (null === paymentMethod) { - return void setTimeout(() => { - waitForPaymentMethod(); - }, 10); - } - - if (-1 === paymentMethod.innerText.toLowerCase().indexOf("bitcoin")) return; - - // If payment method is bitcoin, display instructions and payment button. - pageheader = pageItems.mainHeader.innerText, - pageItems.mainHeader && (pageItems.mainHeader.innerText = "Review and pay!"), - pageItems.continueButton && (pageItems.continueButton.style.visibility = "hidden"), - pageItems.checkMarkIcon && (pageItems.checkMarkIcon.style.visibility = "hidden"), - pageItems.orderConfirmed && (pageItems.orderConfirmed.style.display = "none"), - pageItems.orderConfirmedDescription && (pageItems.orderConfirmedDescription.style.display = "none"); - - const orderId = pageItems.orderNumber.innerText.replace("Order #", ""); - - const url = BTCPAYSERVER_URL + "/invoices" + "?storeId=" + STORE_ID + "&orderId=" + orderId + "&status=complete"; - - // Check if already paid. - fetch(url, { - method: "GET", - mode: "cors", // no-cors, cors, *same-origin, - headers: { - "Content-Type": "application/json", - "accept": "application/json", - }, - }) - .then(function (response) { - return response.json(); - }) - .then(function (json) { - return json.data; - }) - .then(function (data) { - if (data.length != 0) { - orderPaid(); - } - }); - - window.waitForPayment = function () { - buttonElement.innerHTML = "Displaying Invoice..."; - BtcPayServerModal.show( - BTCPAYSERVER_URL, - STORE_ID, - { - price: shopify_price, - currency: shopify_currency, - orderId: orderId - } - ) - .then(function (invoice) { - buttonElement.innerHTML = payButtonHtml; - if (invoice != null) { - orderPaid(); - } - }); - } - - // Payment button that opens modal - const payButtonHtml = ''; - - buttonElement = document.createElement("div"); - buttonElement.innerHTML = payButtonHtml; - insertElement(buttonElement, pageItems.orderConfirmed); - - } - - window.openBtcPayShopify(); -}(); diff --git a/BTCPayServer/wwwroot/shopify/btcpay-shopify.js b/BTCPayServer/wwwroot/shopify/btcpay-shopify.js new file mode 100644 index 000000000..2ba40d764 --- /dev/null +++ b/BTCPayServer/wwwroot/shopify/btcpay-shopify.js @@ -0,0 +1,170 @@ +window.BTCPayShopifyIntegrationModule = function () { + const pageElements = document.querySelector.bind(document); + const insertElement = (document.querySelectorAll.bind(document), + (e, + n) => { + n.parentNode.insertBefore(e, + n.nextSibling) + }); + + // execute BTCPayShopifyIntegrationModule as soon as possible + var paymentMethod = pageElements(".payment-method-list__item__info"); + if (null === paymentMethod) { + return void setTimeout(() => { + window.BTCPayShopifyIntegrationModule(); + }, 10); + } + + if (!window.btcpay) { + throw new Error("The BTCPay modal js was not loaded on this page."); + } + if (!window.Shopify) { + throw new Error("The Shopify global object was not loaded on this page."); + } + if (!window.BTCPAYSERVER_URL || !window.STORE_ID) { + throw new Error("The BTCPAYSERVER_URL STORE_ID global vars were not set on this page."); + } + const shopify_order_id = Shopify.checkout.order_id; + const btcPayServerUrl = window.BTCPAYSERVER_URL; + const storeId = window.STORE_ID; + var currentInvoiceData; + var modalShown = false; + + let buttonElement = null; + + var pageItems = { + mainHeader: pageElements("#main-header"), + orderConfirmed: pageElements(".os-step__title"), + orderConfirmedDescription: pageElements(".os-step__description"), + continueButton: pageElements(".step__footer__continue-btn"), + checkMarkIcon: pageElements(".os-header__hanging-icon"), + orderStatus: pageElements(".os-header__title"), + paymentMethod: pageElements(".payment-method-list__item__info"), + price: pageElements(".payment-due__price"), + finalPrice: pageElements(".total-recap__final-price"), + orderNumber: pageElements(".os-order-number"), + }; + + function setOrderAsPaid() { + document.title = document.title.replace("Review and pay!", "Thank you!"); + pageItems.mainHeader.innerText = "Thank you!", + pageItems.orderConfirmed && (pageItems.orderConfirmed.style.display = "block"), + pageItems.orderConfirmedDescription && (pageItems.orderConfirmedDescription.style.display = "block"), + pageItems.continueButton && (pageItems.continueButton.style.visibility = "visible"), + pageItems.checkMarkIcon && (pageItems.checkMarkIcon.style.visibility = "visible"), + buttonElement && (buttonElement.style.display = "none"); + } + + function showPaymentInstructions() { + document.title = document.title.replace("Thank you!", "Review and pay!"); + pageItems.mainHeader && (pageItems.mainHeader.innerText = "Review and pay!"), + pageItems.continueButton && (pageItems.continueButton.style.visibility = "hidden"), + pageItems.checkMarkIcon && (pageItems.checkMarkIcon.style.visibility = "hidden"), + pageItems.orderConfirmed && (pageItems.orderConfirmed.style.display = "none"), + pageItems.orderConfirmedDescription && (pageItems.orderConfirmedDescription.style.display = "none"); + } + + function getOrCheckInvoice(backgroundCheck) { + const url = btcPayServerUrl + "/stores/" + storeId + "/integrations/shopify/" + shopify_order_id+"?amount="+Shopify.checkout.payment_due+ (backgroundCheck ? "&checkonly=true" : ""); + return fetch(url, { + method: "GET", + mode: "cors", // no-cors, cors, *same-origin, + headers: { + "Content-Type": "application/json", + "accept": "application/json", + } + }) + .then(function (response) { + return response.json(); + }).catch(function () { + if (!backgroundCheck) + alert("Could not initiate BTCPay Server payment method, try again later."); + }) + } + + function onPayButtonClicked() { + buttonElement.innerHTML = "Displaying Invoice..."; + + getOrCheckInvoice().then(handleInvoiceData).catch(fail.bind(this)); + } + + function handleInvoiceData(data, opts) { + currentInvoiceData = data; + if (!currentInvoiceData) { + if (modalShown) { + window.btcpay.hideFrame(); + fail(); + }else if(opts && opts.backgroundCheck){ + injectPaymentButtonHtml(); + }else{ + fail(); + } + return; + } + if (["complete", "confirmed", "paid"].indexOf(currentInvoiceData.status.toLowerCase()) >= 0) { + setOrderAsPaid(); + } else if (["invalid", "expired"].indexOf(currentInvoiceData.status.toLowerCase()) >= 0) { + fail(); + } else if (!opts || !opts.backgroundCheck) { + showModal(); + } + } + + function showModal() { + if (currentInvoiceData && !modalShown) { + modalShown = true; + window.btcpay.setApiUrlPrefix(btcPayServerUrl); + + window.btcpay.onModalReceiveMessage(function (evt) { + if (evt && evt.invoiceId && evt.status) { + currentInvoiceData = evt; + } + }); + + window.btcpay.onModalWillEnter(function () { + modalShown = true; + }); + + window.btcpay.onModalWillLeave(function () { + modalShown = false; + getOrCheckInvoice(true).then(function (d) { + buttonElement.innerHTML = payButtonHtml; + handleInvoiceData(d, {backgroundCheck: true}) + }); + }); + window.btcpay.showInvoice(currentInvoiceData.invoiceId); + } + } + + function fail() { + currentInvoiceData = null; + buttonElement.innerHTML = payButtonHtml; + } + + const payButtonHtml = ''; + + function injectPaymentButtonHtml() { + // Payment button that opens modal + buttonElement = document.getElementById("btcpayserver-pay"); + if (buttonElement) { + return; + } + buttonElement = document.createElement("div"); + buttonElement.id = "btcpayserver-pay"; + buttonElement.innerHTML = payButtonHtml; + insertElement(buttonElement, pageItems.orderConfirmed); + } + + if (["bitcoin", "btc", "btcpayserver", "btcpay server"].filter(value => pageItems.paymentMethod.innerText.toLowerCase().indexOf(value) !== -1).length === 0) { + return; + } + showPaymentInstructions(); + window.onPayButtonClicked = onPayButtonClicked.bind(this); + getOrCheckInvoice(true).then(function (d) { + injectPaymentButtonHtml(); + handleInvoiceData(d, {backgroundCheck: true}) + }); + +}; + +window.BTCPayShopifyIntegrationModule();