mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-28 19:34:23 +01:00
Merge pull request #1908 from btcpayserver/feat/shopify-mark-paid
Shopify registering paid transactions, marking orders paid, finishing integration
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
264
BTCPayServer/Controllers/StoresController.Integrations.cs
Normal file
264
BTCPayServer/Controllers/StoresController.Integrations.cs
Normal file
@@ -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<string> 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<IActionResult> 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<IActionResult> 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<string>() {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<IActionResult> 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});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ApplicationUser> _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
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -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<INotificationHandler, InvoiceEventNotification.Handler>();
|
||||
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
|
||||
|
||||
|
||||
services.AddShopify();
|
||||
#if DEBUG
|
||||
services.AddSingleton<INotificationHandler, JunkNotification.Handler>();
|
||||
#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;
|
||||
});
|
||||
|
||||
15
BTCPayServer/Models/StoreViewModels/IntegrationsViewModel.cs
Normal file
15
BTCPayServer/Models/StoreViewModels/IntegrationsViewModel.cs
Normal file
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -259,11 +259,11 @@ namespace BTCPayServer.Services.Invoices
|
||||
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
|
||||
public HashSet<string> InternalTags { get; set; } = new HashSet<string>();
|
||||
|
||||
public string[] GetInternalTags(string suffix)
|
||||
public string[] GetInternalTags(string prefix)
|
||||
{
|
||||
return InternalTags == null ? Array.Empty<string>() : 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")]
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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<object> Fields { get; set; }
|
||||
|
||||
[JsonProperty("metafield_namespaces")] public List<object> MetafieldNamespaces { get; set; }
|
||||
|
||||
[JsonProperty("api_version")] public string ApiVersion { get; set; }
|
||||
|
||||
[JsonProperty("private_metafield_namespaces")]
|
||||
public List<object> PrivateMetafieldNamespaces { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
10
BTCPayServer/Services/Shopify/ApiModels/OrdersCountResp.cs
Normal file
10
BTCPayServer/Services/Shopify/ApiModels/OrdersCountResp.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BTCPayServer.Services.Shopify.ApiModels
|
||||
{
|
||||
public class CountResponse
|
||||
{
|
||||
[JsonProperty("count")]
|
||||
public long Count { get; set; }
|
||||
}
|
||||
}
|
||||
19
BTCPayServer/Services/Shopify/ApiModels/ShopifyOrder.cs
Normal file
19
BTCPayServer/Services/Shopify/ApiModels/ShopifyOrder.cs
Normal file
@@ -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<ShopifyTransaction> Transactions { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[JsonProperty("error_code")]
|
||||
public string ErrorCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The status of the transaction. Valid values are: pending, failure, success or error.
|
||||
/// </summary>
|
||||
[JsonProperty("status")]
|
||||
public string Status { get; set; }
|
||||
[JsonProperty("test")]
|
||||
public bool? Test { get; set; }
|
||||
[JsonProperty("currency")]
|
||||
public string Currency { get; set; }
|
||||
/// <summary>
|
||||
/// This property is undocumented by Shopify.
|
||||
/// </summary>
|
||||
[JsonProperty("parent_id")]
|
||||
public long? ParentId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Services.Shopify.ApiModels.DataHolders;
|
||||
|
||||
namespace BTCPayServer.Services.Shopify.ApiModels
|
||||
{
|
||||
public class TransactionsListResp
|
||||
{
|
||||
public List<TransactionDataHolder> transactions { get; set; }
|
||||
}
|
||||
}
|
||||
22
BTCPayServer/Services/Shopify/Models/ShopifySettings.cs
Normal file
22
BTCPayServer/Services/Shopify/Models/ShopifySettings.cs
Normal file
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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<TransactionsCreateResp> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
138
BTCPayServer/Services/Shopify/ShopifyApiClient.cs
Normal file
138
BTCPayServer/Services/Shopify/ShopifyApiClient.cs
Normal file
@@ -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<string> 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<CreateWebhookResponse> 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<CreateWebhookResponse>(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<string[]> CheckScopes()
|
||||
{
|
||||
var req = CreateRequest(_credentials.ShopName, HttpMethod.Get, null, "admin/oauth/access_scopes.json");
|
||||
return JObject.Parse(await SendRequest(req))["access_scopes"].Values<JToken>()
|
||||
.Select(token => token["handle"].Value<string>()).ToArray();
|
||||
}
|
||||
|
||||
public async Task<TransactionsListResp> TransactionsList(string orderId)
|
||||
{
|
||||
var req = CreateRequest(_credentials.ShopName, HttpMethod.Get, $"orders/{orderId}/transactions.json");
|
||||
|
||||
var strResp = await SendRequest(req);
|
||||
|
||||
var parsed = JsonConvert.DeserializeObject<TransactionsListResp>(strResp);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
public async Task<TransactionsCreateResp> 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<TransactionsCreateResp>(strResp);
|
||||
}
|
||||
|
||||
public async Task<ShopifyOrder> 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<ShopifyOrder>();
|
||||
}
|
||||
|
||||
public async Task<long> OrdersCount()
|
||||
{
|
||||
var req = CreateRequest(_credentials.ShopName, HttpMethod.Get, $"orders/count.json");
|
||||
var strResp = await SendRequest(req);
|
||||
|
||||
var parsed = JsonConvert.DeserializeObject<CountResponse>(strResp);
|
||||
|
||||
return parsed.Count;
|
||||
}
|
||||
|
||||
public async Task<bool> 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; }
|
||||
}
|
||||
15
BTCPayServer/Services/Shopify/ShopifyApiException.cs
Normal file
15
BTCPayServer/Services/Shopify/ShopifyApiException.cs
Normal file
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
24
BTCPayServer/Services/Shopify/ShopifyExtensions.cs
Normal file
24
BTCPayServer/Services/Shopify/ShopifyExtensions.cs
Normal file
@@ -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<IHostedService, ShopifyOrderMarkerHostedService>();
|
||||
}
|
||||
}
|
||||
}
|
||||
114
BTCPayServer/Services/Shopify/ShopifyOrderMarkerHostedService.cs
Normal file
114
BTCPayServer/Services/Shopify/ShopifyOrderMarkerHostedService.cs
Normal file
@@ -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<InvoiceEvent>();
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
39
BTCPayServer/Views/Stores/Integrations.cshtml
Normal file
39
BTCPayServer/Views/Stores/Integrations.cshtml
Normal file
@@ -0,0 +1,39 @@
|
||||
@model IntegrationsViewModel
|
||||
@{
|
||||
Layout = "../Shared/_NavLayout.cshtml";
|
||||
ViewData.SetActivePageAndTitle(StoreNavPages.Integrations, "Integrations");
|
||||
}
|
||||
|
||||
<partial name="_StatusMessage" />
|
||||
|
||||
@if (!ViewContext.ModelState.IsValid)
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<partial name="Integrations/Shopify"/>
|
||||
|
||||
<h4 class="mb-3 mt-5">
|
||||
Other Integrations
|
||||
</h4>
|
||||
<p>
|
||||
Take a look at documentation for the list of other integrations we support and the directions on how to enable them:
|
||||
<ul>
|
||||
<li><a href="https://docs.btcpayserver.org/WooCommerce/" target="_blank">WooCommerce</a></li>
|
||||
<li><a href="https://docs.btcpayserver.org/Drupal/" target="_blank">Drupal</a></li>
|
||||
<li><a href="https://docs.btcpayserver.org/Magento/" target="_blank">Magento</a></li>
|
||||
<li><a href="https://docs.btcpayserver.org/PrestaShop/" target="_blank">PrestaShop</a></li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
@await Html.PartialAsync("_ValidationScriptsPartial")
|
||||
}
|
||||
81
BTCPayServer/Views/Stores/Integrations/Shopify.cshtml
Normal file
81
BTCPayServer/Views/Stores/Integrations/Shopify.cshtml
Normal file
@@ -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;
|
||||
}
|
||||
<form method="post" id="shopifyForm">
|
||||
<h4 class="mb-3">
|
||||
Shopify
|
||||
<a href="https://docs.btcpayserver.org/Shopify" target="_blank">
|
||||
<span class="fa fa-question-circle-o" title="More information..."></span>
|
||||
</a>
|
||||
</h4>
|
||||
@if (!shopifyCredsSet)
|
||||
{
|
||||
<p class="alert alert-info">Create a Shopify Private App with the permissions "Orders - Read and write"</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="exampleUrl">Example Url from Shopify Private App</label>
|
||||
<input class="form-control" id="exampleUrl" name="exampleUrl">
|
||||
</div>
|
||||
|
||||
<button name="command" type="submit" class="btn btn-primary" value="ShopifySaveCredentials">Connect to Shopify</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="form-group">
|
||||
<label asp-for="Shopify.ShopName"></label>
|
||||
<div class="input-group">
|
||||
@if (!Model.Shopify?.ShopName?.Contains(".") is true)
|
||||
{
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">https://</span>
|
||||
</div>
|
||||
}
|
||||
<input asp-for="Shopify.ShopName" class="form-control" readonly="@shopifyCredsSet" />
|
||||
|
||||
@if (!Model.Shopify?.ShopName?.Contains(".") is true)
|
||||
{
|
||||
<div class="input-group-append">
|
||||
<span class="input-group-text">.myshopify.com</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<span asp-validation-for="Shopify.ShopName" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="Shopify.ApiKey"></label>
|
||||
<input asp-for="Shopify.ApiKey" class="form-control" readonly="@shopifyCredsSet" />
|
||||
<span asp-validation-for="Shopify.ApiKey" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="Shopify.Password"></label>
|
||||
<input asp-for="Shopify.Password" class="form-control" type="password" value="@Model.Shopify?.Password" readonly="@shopifyCredsSet" />
|
||||
<span asp-validation-for="Shopify.Password" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="alert alert-warning">
|
||||
<p>
|
||||
In Shopify please paste following script at <a href="@shopifyUrl/admin/settings/checkout#PolarisTextField1" target="_blank" class="font-weight-bold"> Settings > Checkout > Order Processing > Additional Scripts</a>
|
||||
</p>
|
||||
<kbd style="display: block; word-break: break-all;">
|
||||
@($"<script src='{Url.Action("ShopifyJavascript", "Stores", new {storeId = Context.GetRouteValue("storeId")}, Context.Request.Scheme)}'></script>")
|
||||
</kbd>
|
||||
</div>
|
||||
<p class="alert alert-warning">
|
||||
In Shopify please add a payment method at <a target="_blank" href="@shopifyUrl/admin/settings/payments" class="font-weight-bold"> Settings > Payments > Manual Payment Methods</a> with the name <kbd>Bitcoin with BTCPay Server</kbd>
|
||||
</p>
|
||||
|
||||
<p class="alert alert-success">
|
||||
Orders on <b>@shopify.ShopName</b>.myshopify.com will be marked as paid on successful invoice payment.
|
||||
Started: @shopify.IntegratedAt.Value.ToBrowserDate()
|
||||
</p>
|
||||
|
||||
<button name="command" type="submit" class="btn btn-danger" value="ShopifyClearCredentials">
|
||||
Stop Shopify calls and clear credentials
|
||||
</button>
|
||||
}
|
||||
|
||||
</form>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<div class="nav flex-column nav-pills mb-4">
|
||||
<a id="@(nameof(StoreNavPages.Index))"class="nav-link @ViewData.IsActivePage(StoreNavPages.Index)" asp-controller="Stores" asp-action="UpdateStore" asp-route-storeId="@this.Context.GetRouteValue("storeId")">General settings</a>
|
||||
<a id="@(nameof(StoreNavPages.Rates))"class="nav-link @ViewData.IsActivePage(StoreNavPages.Rates)" asp-controller="Stores" asp-action="Rates" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Rates</a>
|
||||
<a id="@(nameof(StoreNavPages.Checkout))"class="nav-link @ViewData.IsActivePage(StoreNavPages.Checkout)" asp-controller="Stores" asp-action="CheckoutExperience" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Checkout experience</a>
|
||||
<a id="@(nameof(StoreNavPages.Tokens))"class="nav-link @ViewData.IsActivePage(StoreNavPages.Tokens)" asp-controller="Stores" asp-action="ListTokens" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Access Tokens</a>
|
||||
<a id="@(nameof(StoreNavPages.Users))"class="nav-link @ViewData.IsActivePage(StoreNavPages.Users)" asp-controller="Stores" asp-action="StoreUsers" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Users</a>
|
||||
<a id="@(nameof(StoreNavPages.PayButton))"class="nav-link @ViewData.IsActivePage(StoreNavPages.PayButton)" asp-controller="Stores" asp-action="PayButton" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Pay Button</a>
|
||||
<a id="@(nameof(StoreNavPages.Index))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Index)" asp-controller="Stores" asp-action="UpdateStore" asp-route-storeId="@this.Context.GetRouteValue("storeId")">General settings</a>
|
||||
<a id="@(nameof(StoreNavPages.Rates))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Rates)" asp-controller="Stores" asp-action="Rates" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Rates</a>
|
||||
<a id="@(nameof(StoreNavPages.Checkout))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Checkout)" asp-controller="Stores" asp-action="CheckoutExperience" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Checkout experience</a>
|
||||
<a id="@(nameof(StoreNavPages.Tokens))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Tokens)" asp-controller="Stores" asp-action="ListTokens" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Access Tokens</a>
|
||||
<a id="@(nameof(StoreNavPages.Users))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Users)" asp-controller="Stores" asp-action="StoreUsers" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Users</a>
|
||||
<a id="@(nameof(StoreNavPages.PayButton))" class="nav-link @ViewData.IsActivePage(StoreNavPages.PayButton)" asp-controller="Stores" asp-action="PayButton" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Pay Button</a>
|
||||
<a id="@(nameof(StoreNavPages.Integrations))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Integrations)" asp-controller="Stores" asp-action="Integrations" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Integrations</a>
|
||||
@inject IEnumerable<BTCPayServer.Contracts.IStoreNavExtension> Extensions;
|
||||
@foreach (var extension in Extensions)
|
||||
{
|
||||
<partial name="@extension.Partial"/>
|
||||
<partial name="@extension.Partial" />
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,5 +205,12 @@
|
||||
"minify": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"outputFileName": "wwwroot/bundles/shopify-bundle.min.js",
|
||||
"inputFiles": [
|
||||
"wwwroot/modal/btcpay.js",
|
||||
"wwwroot/shopify/btcpay-shopify.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
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -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.
|
||||
<script>
|
||||
const BTCPAYSERVER_URL = "FULL_BTCPAYSERVER_URL_WITH_HTTPS";
|
||||
const STORE_ID = "YOUR_BTCPAY_STORE_ID";
|
||||
</script>
|
||||
<script src="FULL_BTCPAYSERVER_URL_WITH_HTTPS/modal/btcpay.js"></script>
|
||||
<script src="FULL_BTCPAYSERVER_URL_WITH_HTTPS/shopify/btcpay-browser-client.js"></script>
|
||||
<script src="FULL_BTCPAYSERVER_URL_WITH_HTTPS/shopify/btcpay-shopify-checkout.js"></script>
|
||||
|
||||
*/
|
||||
|
||||
! 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 = "<span>Displaying Invoice...</span>";
|
||||
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 = '<button class="" onclick="window.waitForPayment()" style="width:210px; border: none; outline: none;"><img src="' + BTCPAYSERVER_URL + '/img/paybutton/pay.svg"></button>';
|
||||
|
||||
buttonElement = document.createElement("div");
|
||||
buttonElement.innerHTML = payButtonHtml;
|
||||
insertElement(buttonElement, pageItems.orderConfirmed);
|
||||
|
||||
}
|
||||
|
||||
window.openBtcPayShopify();
|
||||
}();
|
||||
170
BTCPayServer/wwwroot/shopify/btcpay-shopify.js
Normal file
170
BTCPayServer/wwwroot/shopify/btcpay-shopify.js
Normal file
@@ -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 = "<span>Displaying Invoice...</span>";
|
||||
|
||||
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 = '<button class="" onclick="onPayButtonClicked()" style="width:210px; border: none; outline: none;"><img src="' + btcPayServerUrl + '/img/paybutton/pay.svg"></button>';
|
||||
|
||||
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();
|
||||
Reference in New Issue
Block a user