Merge pull request #1908 from btcpayserver/feat/shopify-mark-paid

Shopify registering paid transactions, marking orders paid, finishing integration
This commit is contained in:
Nicolas Dorier
2020-09-30 11:23:56 +09:00
committed by GitHub
33 changed files with 1241 additions and 253 deletions

View File

@@ -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);
}

View 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});
}
}
}

View File

@@ -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
});
}
}
}

View File

@@ -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

View File

@@ -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);
}
}));

View File

@@ -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;
});

View 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; }
}
}

View File

@@ -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")]

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,10 @@
using Newtonsoft.Json;
namespace BTCPayServer.Services.Shopify.ApiModels
{
public class CountResponse
{
[JsonProperty("count")]
public long Count { get; set; }
}
}

View 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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View 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; }
}
}

View File

@@ -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;
}
}
}

View 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; }
}

View 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)
{
}
}
}

View 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>();
}
}
}

View 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());
}
}
}

View 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")
}

View 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 &gt; Checkout &gt; Order Processing &gt; 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 &gt; Payments &gt; 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>

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -205,5 +205,12 @@
"minify": {
"enabled": false
}
}
},
{
"outputFileName": "wwwroot/bundles/shopify-bundle.min.js",
"inputFiles": [
"wwwroot/modal/btcpay.js",
"wwwroot/shopify/btcpay-shopify.js"
]
}
]

View File

@@ -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
};
})();

View File

@@ -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();
}
}
})()

View File

@@ -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();
}();

View 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();