Init Commit
This commit is contained in:
commit
b3f606546e
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@ -0,0 +1,25 @@
|
||||
**/.dockerignore
|
||||
**/.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/.project
|
||||
**/.settings
|
||||
**/.toolstarget
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/.idea
|
||||
**/*.*proj.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/azds.yaml
|
||||
**/bin
|
||||
**/charts
|
||||
**/docker-compose*
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
**/bin/**/*
|
||||
**/obj
|
||||
.idea
|
||||
Plugins/packed
|
||||
.vs/
|
||||
monero_wallet/
|
||||
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
[submodule "btcpayserver"]
|
||||
path = btcpayserver
|
||||
url = https://github.com/btcpayserver/btcpayserver
|
||||
[submodule "submodules/btcpayserver"]
|
||||
path = submodules/btcpayserver
|
||||
url = https://github.com/btcpayserver/btcpayserver
|
||||
@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<UserSecretsId>b84e3bdc-081d-476c-828e-df82abbfd9c7</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\submodules\btcpayserver\BTCPayServer\BTCPayServer.csproj" />
|
||||
<ProjectReference Include="..\BTCPayServer.Plugins.ShopifyPlugin\BTCPayServer.Plugins.ShopifyPlugin.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
132
Plugins/BTCPayServer.Plugins.ShopifyPlugin.Tests/UnitTest1.cs
Normal file
132
Plugins/BTCPayServer.Plugins.ShopifyPlugin.Tests/UnitTest1.cs
Normal file
@ -0,0 +1,132 @@
|
||||
|
||||
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.Services;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Text;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.Tests
|
||||
{
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
// This test fail if the token_id is too old
|
||||
public async Task CanRequestOfflineToken()
|
||||
{
|
||||
ShopifyAppClient client = CreateAppClient();
|
||||
// This token is probably too old now. You can get that from the query string when "Go to the app" on shopify store's admin interface
|
||||
var token_id = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczpcL1wvYW5qMWJzLXB1Lm15c2hvcGlmeS5jb21cL2FkbWluIiwiZGVzdCI6Imh0dHBzOlwvXC9hbmoxYnMtcHUubXlzaG9waWZ5LmNvbSIsImF1ZCI6ImMyZjUyZDBkOWYwNDBjOGRlMmU5MmQ5NzhhYzcwNmE4Iiwic3ViIjoiMTE2Mzc0MzM5OTA5IiwiZXhwIjoxNzM5Nzc0Nzk1LCJuYmYiOjE3Mzk3NzQ3MzUsImlhdCI6MTczOTc3NDczNSwianRpIjoiYTVlMWE1ZjEtN2JmOS00NzE0LWFhMzQtYWNmYTAyNmIxMjE5Iiwic2lkIjoiNTYwMGUxOGQtNzNmNS00ZjM4LWFhMTgtMTM5NzZiNjZkODllIiwic2lnIjoiODVmNjRmNzgxODI1ODZmYWI4ZWI0NTU0NjY0NWY3OGYxNjkxMDA5ZTU4MDkzYTljZWVhYzkyZTQ5YzA1ZTM0MCJ9.jjjERtdCAXGxL5WB0YlyaorVWgF190bU6UoyVeyfN0U";
|
||||
var v = client.ValidateSessionToken(token_id);
|
||||
var access = await client.GetAccessToken(v.ShopUrl, token_id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanValidateTokenId()
|
||||
{
|
||||
ShopifyAppClient client = CreateAppClient();
|
||||
var token_id = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczpcL1wvYW5qMWJzLXB1Lm15c2hvcGlmeS5jb21cL2FkbWluIiwiZGVzdCI6Imh0dHBzOlwvXC9hbmoxYnMtcHUubXlzaG9waWZ5LmNvbSIsImF1ZCI6ImMyZjUyZDBkOWYwNDBjOGRlMmU5MmQ5NzhhYzcwNmE4Iiwic3ViIjoiMTE2Mzc0MzM5OTA5IiwiZXhwIjoxNzM5Nzc0Nzk1LCJuYmYiOjE3Mzk3NzQ3MzUsImlhdCI6MTczOTc3NDczNSwianRpIjoiYTVlMWE1ZjEtN2JmOS00NzE0LWFhMzQtYWNmYTAyNmIxMjE5Iiwic2lkIjoiNTYwMGUxOGQtNzNmNS00ZjM4LWFhMTgtMTM5NzZiNjZkODllIiwic2lnIjoiODVmNjRmNzgxODI1ODZmYWI4ZWI0NTU0NjY0NWY3OGYxNjkxMDA5ZTU4MDkzYTljZWVhYzkyZTQ5YzA1ZTM0MCJ9.jjjERtdCAXGxL5WB0YlyaorVWgF190bU6UoyVeyfN0U";
|
||||
var v = client.ValidateSessionToken(token_id, skipLifeTimeCheck: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanValidateQueryString()
|
||||
{
|
||||
ShopifyAppClient client = CreateAppClient();
|
||||
var queryString = "embedded=1&hmac=76f8f87414ad8fd7feb0f38f127b8aeee18faa9964804d5332cdd51d1317aba1&host=YWRtaW4uc2hvcGlmeS5jb20vc3RvcmUvYW5qMWJzLXB1&id_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczpcL1wvYW5qMWJzLXB1Lm15c2hvcGlmeS5jb21cL2FkbWluIiwiZGVzdCI6Imh0dHBzOlwvXC9hbmoxYnMtcHUubXlzaG9waWZ5LmNvbSIsImF1ZCI6ImMyZjUyZDBkOWYwNDBjOGRlMmU5MmQ5NzhhYzcwNmE4Iiwic3ViIjoiMTE2Mzc0MzM5OTA5IiwiZXhwIjoxNzM5Nzc1MzMyLCJuYmYiOjE3Mzk3NzUyNzIsImlhdCI6MTczOTc3NTI3MiwianRpIjoiZTQzMzg0ZTItOTNmMy00NGJjLWFkMjMtMzMxMzVmYWUxOWQyIiwic2lkIjoiNTYwMGUxOGQtNzNmNS00ZjM4LWFhMTgtMTM5NzZiNjZkODllIiwic2lnIjoiZDI4MzQ2OTc2Zjg4NjE1M2EwODUzNDNjNTYzMDJlNWM5MmQ1ZGE5NTE3NjJkZjg5MWFiMGZmYmQ4YTRmMzYwNSJ9.ZYzI8TGAd0jZ9C1itKPjgfIESokqrP8CTo36v7dxSwE&locale=en&session=4873652bcf48a37f927137f1a1121dfdafd70bebbbdd1490c23e721a9337b5e8&shop=anj1bs-pu.myshopify.com×tamp=1739775272";
|
||||
Assert.True(client.ValidateQueryString(queryString, skipLifeTimeCheck: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanQueryOrderByCheckoutToken()
|
||||
{
|
||||
ShopifyApiClient client = CreateApiClient();
|
||||
var checkoutToken = "ac0565ed327a6fc0011d0dbed8186c16";
|
||||
var order = await client.GetOrderByCheckoutToken(checkoutToken);
|
||||
Assert.NotNull(order);
|
||||
Assert.Null(await client.GetOrderByCheckoutToken("lol"));
|
||||
}
|
||||
[Fact]
|
||||
public async Task CanQueryOrder()
|
||||
{
|
||||
ShopifyApiClient client = CreateApiClient();
|
||||
var draftTemplate = 1460965376325L;
|
||||
|
||||
var newDraft = await client.DuplicateOrder(draftTemplate);
|
||||
var orderId = await client.CompleteDraftOrder(newDraft.Id);
|
||||
|
||||
|
||||
var o = await client.GetOrder(orderId.Id);
|
||||
Assert.Null(o.Transactions);
|
||||
Assert.Equal(ShopifyId.Order(orderId.Id), o.Id);
|
||||
Assert.NotNull(o);
|
||||
Assert.Null((await client.GetOrder(1)));
|
||||
|
||||
o = await client.GetOrder(orderId.Id, true);
|
||||
Assert.NotNull(o.Transactions);
|
||||
|
||||
await client.CaptureOrder(new CaptureOrderRequest()
|
||||
{
|
||||
Id = o.Id,
|
||||
Amount = o.TotalOutstandingSet.ShopMoney.Amount,
|
||||
Currency = o.TotalOutstandingSet.ShopMoney.CurrencyCode,
|
||||
ParentTransactionId = o.Transactions.First().Id
|
||||
});
|
||||
o = await client.GetOrder(orderId.Id, true);
|
||||
|
||||
var cancelReq = new CancelOrderRequest()
|
||||
{
|
||||
OrderId = o.Id,
|
||||
NotifyCustomer = false,
|
||||
Reason = ViewModels.OrderCancelReason.DECLINED,
|
||||
Refund = false,
|
||||
Restock = true,
|
||||
StaffNote = "lol"
|
||||
};
|
||||
await client.CancelOrder(cancelReq);
|
||||
// Doesn't throw if done fast enough... but with a delay that throw as expected
|
||||
// await Assert.ThrowsAsync<ShopifyApiException>(() => client.CancelOrder(cancelReq));
|
||||
|
||||
|
||||
}
|
||||
|
||||
private ShopifyAppClient CreateAppClient()
|
||||
{
|
||||
// dotnet user-secrets set "API_CLIENT" "YOUR_API_CLIENT"
|
||||
// dotnet user-secrets set "API_SECRET" "YOUR_API_SECRET"
|
||||
var conf = GetConf();
|
||||
if (conf["API_CLIENT"] is not string apiClient)
|
||||
throw new InvalidOperationException("Please, set your dev environment with: dotnet user-secrets set \"API_CLIENT\" \"YOUR_API_CLIENT\"");
|
||||
if (conf["API_SECRET"] is not string apiSecret)
|
||||
throw new InvalidOperationException("Please, set your dev environment with: dotnet user-secrets set \"API_SECRET\" \"YOUR_API_SECRET\"");
|
||||
return new ShopifyAppClient(new HttpClient(), new ShopifyAppCredentials("c2f52d0d9f040c8de2e92d978ac706a8", "f4110cea690d160b789f1b541a5ef4ce"));
|
||||
}
|
||||
|
||||
private static IConfigurationRoot GetConf()
|
||||
{
|
||||
var builder = new ConfigurationBuilder()
|
||||
.AddUserSecrets<UnitTest1>();
|
||||
var conf = builder.Build();
|
||||
return conf;
|
||||
}
|
||||
|
||||
private static ShopifyApiClient CreateApiClient()
|
||||
{
|
||||
// Test need write/read access to order and draft orders
|
||||
// write_payment_sessions
|
||||
// See https://shopify.dev/docs/api/payments-apps/2024-04/mutations/paymentSessionResolve
|
||||
|
||||
// dotnet user-secrets set "ACCESS_TOKEN" "YOU_ACCESS_TOKEN"
|
||||
var conf = GetConf();
|
||||
if (conf["ACCESS_TOKEN"] is not string accessToken)
|
||||
throw new InvalidOperationException("Please, set your dev environment with: dotnet user-secrets set \"ACCESS_TOKEN\" \"YOUR_ACCESS_TOKEN\"");
|
||||
|
||||
return new ShopifyApiClient(new HttpClient(),
|
||||
"https://anj1bs-pu.myshopify.com/",
|
||||
new ShopifyApiClientCredentials.AccessToken(accessToken));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Plugin specific properties -->
|
||||
<PropertyGroup>
|
||||
<Product>BTCPay Server Shopify Plugin v2</Product>
|
||||
<Description>BTCPay server integration with Shopify.</Description>
|
||||
<Version>1.0.1</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Plugin development properties -->
|
||||
<PropertyGroup>
|
||||
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
|
||||
<PreserveCompilationContext>false</PreserveCompilationContext>
|
||||
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- This will make sure that referencing BTCPayServer doesn't put any artifact in the published directory -->
|
||||
<ItemDefinitionGroup>
|
||||
<ProjectReference>
|
||||
<Properties>StaticWebAssetsEnabled=false</Properties>
|
||||
<Private>false</Private>
|
||||
<ExcludeAssets>runtime;native;build;buildTransitive;contentFiles</ExcludeAssets>
|
||||
</ProjectReference>
|
||||
</ItemDefinitionGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\submodules\btcpayserver\BTCPayServer\BTCPayServer.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@ -0,0 +1,284 @@
|
||||
#nullable enable
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using BTCPayServer.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.Services;
|
||||
using System.Net.Http;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
using BTCPayServer.Client.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using StoreData = BTCPayServer.Data.StoreData;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using System.Text.RegularExpressions;
|
||||
using static Dapper.SqlMapper;
|
||||
using BTCPayServer.Plugins.Shopify.Models;
|
||||
using NBitpayClient;
|
||||
using System.Globalization;
|
||||
using BTCPayServer.Lightning.LndHub;
|
||||
using System.Threading;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin;
|
||||
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewProfile)]
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public class UIShopifyController : Controller
|
||||
{
|
||||
private readonly StoreRepository _storeRepo;
|
||||
private readonly InvoiceRepository _invoiceRepository;
|
||||
private readonly UIInvoiceController _invoiceController;
|
||||
public UIShopifyController
|
||||
(
|
||||
ShopifyClientFactory shopifyClientFactory,
|
||||
StoreRepository storeRepo,
|
||||
UIInvoiceController invoiceController,
|
||||
InvoiceRepository invoiceRepository)
|
||||
{
|
||||
_storeRepo = storeRepo;
|
||||
ShopifyClientFactory = shopifyClientFactory;
|
||||
_invoiceRepository = invoiceRepository;
|
||||
_invoiceController = invoiceController;
|
||||
}
|
||||
public StoreData CurrentStore => HttpContext.GetStoreData();
|
||||
|
||||
public ShopifyClientFactory ShopifyClientFactory { get; }
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("~/stores/{storeId}/plugins/shopify")]
|
||||
public async Task<IActionResult> Index(string storeId, string? id_token = null)
|
||||
{
|
||||
if (id_token is not null)
|
||||
{
|
||||
var appClient = await ShopifyClientFactory.CreateAppClient(storeId);
|
||||
if (appClient is null)
|
||||
return NotFound();
|
||||
if (!appClient.ValidateQueryString(this.HttpContext.Request.QueryString.ToString()))
|
||||
return NotFound();
|
||||
var t = appClient.ValidateSessionToken(id_token);
|
||||
var accessToken = await appClient.GetAccessToken(t.ShopUrl, id_token);
|
||||
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName) ?? new ShopifyStoreSettings(); // Should not be null as we have appClient
|
||||
if (settings.ShopUrl is null || settings.AccessToken is null)
|
||||
{
|
||||
this.TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Message = "Shopify plugin successfully configured",
|
||||
Severity = StatusMessageModel.StatusSeverity.Success
|
||||
});
|
||||
settings.ShopUrl = t.ShopUrl;
|
||||
settings.AccessToken = accessToken.AccessToken;
|
||||
await _storeRepo.UpdateSetting(storeId, ShopifyStoreSettings.SettingsName, settings);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (settings.ShopUrl != t.ShopUrl)
|
||||
{
|
||||
this.TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Message = "The Shopify plugin is configured with a different store. Reset this configuration if you want to re-configure the plugin.",
|
||||
Severity = StatusMessageModel.StatusSeverity.Error
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
this.TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Message = "The Shopify plugin is already configured",
|
||||
Severity = StatusMessageModel.StatusSeverity.Success
|
||||
});
|
||||
if (settings.AccessToken != accessToken.AccessToken)
|
||||
{
|
||||
settings.AccessToken = accessToken.AccessToken;
|
||||
await _storeRepo.UpdateSetting(storeId, ShopifyStoreSettings.SettingsName, settings);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return RedirectToAction(nameof(Settings), new { storeId });
|
||||
}
|
||||
|
||||
[Route("~/stores/{storeId}/plugins/shopify/settings")]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)]
|
||||
public async Task<IActionResult> Settings(string storeId,
|
||||
ShopifySettingsViewModel vm, [FromForm] string? command = null)
|
||||
{
|
||||
if (command == "SaveAppSettings")
|
||||
{
|
||||
if (vm.ClientId is not null)
|
||||
{
|
||||
vm.ClientId = vm.ClientId.Trim();
|
||||
if (!Regex.IsMatch(vm.ClientId, "[a-f0-9]{32,32}"))
|
||||
{
|
||||
ModelState.AddModelError(nameof(vm.ClientId), "Invalid client id");
|
||||
}
|
||||
}
|
||||
if (vm.ClientSecret is not null)
|
||||
{
|
||||
vm.ClientSecret = vm.ClientSecret.Trim();
|
||||
if (!Regex.IsMatch(vm.ClientSecret, "[a-f0-9]{32,32}"))
|
||||
{
|
||||
ModelState.AddModelError(nameof(vm.ClientSecret), "Invalid client secret");
|
||||
}
|
||||
}
|
||||
if (!ModelState.IsValid)
|
||||
return View("/Views/UIShopify/Settings.cshtml", vm);
|
||||
var settings = new ShopifyStoreSettings();
|
||||
settings.App = new ShopifyStoreSettings.AppCreds
|
||||
{
|
||||
ClientId = vm.ClientId,
|
||||
ClientSecret = vm.ClientSecret
|
||||
};
|
||||
await _storeRepo.UpdateSetting(storeId, ShopifyStoreSettings.SettingsName, settings);
|
||||
this.TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Message = "App settings saved",
|
||||
Severity = StatusMessageModel.StatusSeverity.Success
|
||||
});
|
||||
return RedirectToAction(nameof(Settings), new { storeId });
|
||||
}
|
||||
if (command == "Reset")
|
||||
{
|
||||
await _storeRepo.UpdateSetting<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName, null!);
|
||||
this.TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Message = "App settings reset",
|
||||
Severity = StatusMessageModel.StatusSeverity.Success
|
||||
});
|
||||
return RedirectToAction(nameof(Settings), new { storeId });
|
||||
}
|
||||
else // (command is null)
|
||||
{
|
||||
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName);
|
||||
return View("/Views/UIShopify/Settings.cshtml", new ShopifySettingsViewModel()
|
||||
{
|
||||
ClientId = settings?.App?.ClientId,
|
||||
ClientSecret = settings?.App?.ClientSecret,
|
||||
ShopUrl = settings?.ShopUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
static AsyncDuplicateLock OrderLocks = new AsyncDuplicateLock();
|
||||
[AllowAnonymous]
|
||||
[HttpGet("~/stores/{storeId}/plugins/shopify/checkout")]
|
||||
public async Task<IActionResult> Checkout(string storeId, string? checkout_token, CancellationToken cancellationToken)
|
||||
{
|
||||
if (checkout_token is null)
|
||||
return BadRequest("Invalid checkout token");
|
||||
var client = await this.ShopifyClientFactory.CreateAPIClient(storeId);
|
||||
if (client is null)
|
||||
return BadRequest("Shopify plugin isn't configured properly");
|
||||
var order = await client.GetOrderByCheckoutToken(checkout_token);
|
||||
var store = await _storeRepo.FindStore(storeId);
|
||||
if (order is null || store is null)
|
||||
return BadRequest("Invalid checkout token");
|
||||
|
||||
var orderId = order.Id.Id;
|
||||
var searchTerm = $"{Extensions.SHOPIFY_ORDER_ID_PREFIX}{orderId}";
|
||||
var invoices = await _invoiceRepository.GetInvoices(new InvoiceQuery()
|
||||
{
|
||||
TextSearch = searchTerm,
|
||||
StoreId = new[] { storeId }
|
||||
});
|
||||
|
||||
// This prevent a race condition where two invoices get created for same order
|
||||
using var l = await OrderLocks.LockAsync(orderId, cancellationToken);
|
||||
|
||||
var orderInvoices =
|
||||
invoices.Where(e => e.GetShopifyOrderId() == orderId).ToArray();
|
||||
var currentInvoice = orderInvoices.FirstOrDefault();
|
||||
if (currentInvoice != null)
|
||||
return RedirectToInvoiceCheckout(currentInvoice.Id);
|
||||
var amount = order.TotalOutstandingSet.PresentmentMoney;
|
||||
var invoice = await _invoiceController.CreateInvoiceCoreRaw(
|
||||
new CreateInvoiceRequest()
|
||||
{
|
||||
Amount = amount.Amount,
|
||||
Currency = amount.CurrencyCode,
|
||||
Metadata = new JObject
|
||||
{
|
||||
["orderId"] = order.Name,
|
||||
["shopifyOrderId"] = orderId,
|
||||
["shopifyOrderName"] = order.Name
|
||||
},
|
||||
AdditionalSearchTerms = new[]
|
||||
{
|
||||
order.Name,
|
||||
orderId.ToString(CultureInfo.InvariantCulture),
|
||||
searchTerm
|
||||
}
|
||||
}, store,
|
||||
Request.GetAbsoluteRoot(), new List<string>() { searchTerm });
|
||||
return RedirectToInvoiceCheckout(invoice.Id);
|
||||
}
|
||||
|
||||
private IActionResult RedirectToInvoiceCheckout(string invoiceId)
|
||||
{
|
||||
return RedirectToAction(nameof(UIInvoiceController.Checkout), "UIInvoice",
|
||||
new { invoiceId });
|
||||
}
|
||||
|
||||
record WebhookInfo(string HMac, string FullTopicName);
|
||||
static WebhookInfo? GetWebhookInfoFromHeader(HttpRequest request)
|
||||
{
|
||||
string? GetHeader(string name)
|
||||
{
|
||||
if (!request.Headers.TryGetValue(name, out StringValues o))
|
||||
return null;
|
||||
return o.ToString();
|
||||
}
|
||||
if (GetHeader("X-Shopify-Hmac-SHA256") is string hmac &&
|
||||
GetHeader("X-Shopify-Topic") is string topic &&
|
||||
GetHeader("X-Shopify-Sub-Topic") is string subtopic)
|
||||
return new WebhookInfo(hmac, $"{topic}/{subtopic}");
|
||||
return null;
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[IgnoreAntiforgeryToken]
|
||||
[HttpPost("~/stores/{storeId}/plugins/shopify/webhooks")]
|
||||
// We actually do not use it, but shopify requires to still listen to it...
|
||||
// leaving it here.
|
||||
public async Task<IActionResult> Webhook(string storeId)
|
||||
{
|
||||
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName);
|
||||
string requestBody;
|
||||
using (var reader = new StreamReader(Request.Body, Encoding.UTF8))
|
||||
{
|
||||
requestBody = await reader.ReadToEndAsync();
|
||||
}
|
||||
var webhookInfo = GetWebhookInfoFromHeader(Request);
|
||||
if (webhookInfo is null)
|
||||
return BadRequest("Missing webhook info in HTTP headers");
|
||||
|
||||
var client = await this.ShopifyClientFactory.CreateAppClient(storeId);
|
||||
if (client is null)
|
||||
return NotFound();
|
||||
if (!client.VerifyWebhookSignature(requestBody, webhookInfo.HMac))
|
||||
return Unauthorized("Invalid HMAC signature");
|
||||
|
||||
// https://shopify.dev/docs/api/webhooks?reference=toml#list-of-topics-orders/create
|
||||
//if (webhookInfo.FullTopicName == "orders/create")
|
||||
//{
|
||||
// var order = JsonConvert.DeserializeObject<dynamic>(requestBody)!;
|
||||
// checkoutTokens.Add(new(storeId, (string)order.checkout_token), (long)order.id);
|
||||
//}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
24
Plugins/BTCPayServer.Plugins.ShopifyPlugin/Extensions.cs
Normal file
24
Plugins/BTCPayServer.Plugins.ShopifyPlugin/Extensions.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.Services;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using MailKit.Search;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin
|
||||
{
|
||||
public static class Extensions
|
||||
{
|
||||
public const string SHOPIFY_ORDER_ID_PREFIX = "shopify-";
|
||||
public static long? GetShopifyOrderId(this InvoiceEntity e)
|
||||
=> e
|
||||
.GetInternalTags(SHOPIFY_ORDER_ID_PREFIX)
|
||||
.Select(e => long.TryParse(e, CultureInfo.InvariantCulture, out var v) ? v : (long?)null)
|
||||
.Where(e => e is not null)
|
||||
.FirstOrDefault();
|
||||
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
#nullable enable
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.JsonConverters
|
||||
{
|
||||
public class ShopifyIdJsonConverter : JsonConverter<ShopifyId>
|
||||
{
|
||||
public override ShopifyId? ReadJson(JsonReader reader, Type objectType, ShopifyId? existingValue, bool hasExistingValue, JsonSerializer serializer)
|
||||
=> reader switch
|
||||
{
|
||||
{ TokenType: JsonToken.Null } => null,
|
||||
{ TokenType: JsonToken.String, Value: string str } when ShopifyId.TryParse(str, out var id) => id,
|
||||
_ => null
|
||||
};
|
||||
|
||||
public override void WriteJson(JsonWriter writer, ShopifyId? value, JsonSerializer serializer)
|
||||
{
|
||||
if (value is { } v)
|
||||
writer.WriteValue(v.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
24
Plugins/BTCPayServer.Plugins.ShopifyPlugin/Plugin.cs
Normal file
24
Plugins/BTCPayServer.Plugins.ShopifyPlugin/Plugin.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Abstractions.Services;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.Services;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin;
|
||||
|
||||
public class Plugin : BaseBTCPayServerPlugin
|
||||
{
|
||||
public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } =
|
||||
{
|
||||
new IBTCPayServerPlugin.PluginDependency { Identifier = nameof(BTCPayServer), Condition = ">=2.0.0" }
|
||||
};
|
||||
|
||||
public override void Execute(IServiceCollection services)
|
||||
{
|
||||
services.AddUIExtension("header-nav", "ShopifyPluginHeaderNav");
|
||||
services.AddSingleton<ShopifyClientFactory>();
|
||||
services.AddHostedService<ShopifyHostedService>();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,417 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Generic;
|
||||
using Google.Apis.Auth.OAuth2;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using System.Globalization;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.JsonConverters;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Security;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.Services
|
||||
{
|
||||
public class ShopifyAppClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ShopifyAppCredentials _credentials;
|
||||
|
||||
public ShopifyAppClient(HttpClient httpClient, ShopifyAppCredentials appCredentials)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
this._credentials = appCredentials;
|
||||
}
|
||||
/// <summary>
|
||||
/// Validate a session token
|
||||
/// </summary>
|
||||
/// <param name="sessionId"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="SecurityTokenInvalidIssuerException">storeUrl</exception>
|
||||
public (string ShopUrl, string Issuer) ValidateSessionToken(string sessionId, bool skipLifeTimeCheck = false)
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var token = handler.ReadJwtToken(sessionId);
|
||||
handler.ValidateToken(sessionId, new TokenValidationParameters()
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = !skipLifeTimeCheck,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_credentials.ClientSecret)),
|
||||
ValidAudiences = [_credentials.ClientId],
|
||||
}, out _);
|
||||
var storeUrl = token.Claims.FirstOrDefault(c => c.Type == "dest")?.Value;
|
||||
if (!token.Issuer.StartsWith(storeUrl))
|
||||
throw new SecurityTokenInvalidIssuerException("Invalid Issuer");
|
||||
return (storeUrl, token.Issuer);
|
||||
}
|
||||
public async Task<AccessTokenResponse> GetAccessToken(string shopUrl, string sessionId)
|
||||
{
|
||||
ValidateSessionToken(sessionId);
|
||||
var body = new JObject()
|
||||
{
|
||||
["client_id"] = _credentials.ClientId,
|
||||
["client_secret"] = _credentials.ClientSecret,
|
||||
["grant_type"] = "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
["subject_token"] = sessionId,
|
||||
["subject_token_type"] = "urn:ietf:params:oauth:token-type:id_token",
|
||||
["requested_token_type"] = "urn:shopify:params:oauth:token-type:offline-access-token"
|
||||
};
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, $"{shopUrl}/admin/oauth/access_token");
|
||||
req.Content = new StringContent(body.ToString(), Encoding.UTF8, "application/json");
|
||||
req.Headers.Add("Accept", "application/json");
|
||||
using var resp = await _httpClient.SendAsync(req);
|
||||
var strResp = await resp.Content?.ReadAsStringAsync();
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new ShopifyApiException($"Error while getting access token (HTTP {resp.StatusCode}): {strResp}");
|
||||
return JsonConvert.DeserializeObject<AccessTokenResponse>(strResp);
|
||||
}
|
||||
|
||||
public bool VerifyWebhookSignature(string body, string hmac)
|
||||
{
|
||||
var keyBytes = Encoding.UTF8.GetBytes(_credentials.ClientSecret);
|
||||
using (var hmacObj = new HMACSHA256(keyBytes))
|
||||
{
|
||||
var hashBytes = hmacObj.ComputeHash(Encoding.UTF8.GetBytes(body));
|
||||
var hashString = Convert.ToBase64String(hashBytes);
|
||||
return hashString.Equals(hmac, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
public bool ValidateQueryString(string queryString, bool skipLifeTimeCheck = false)
|
||||
{
|
||||
var query = QueryHelpers.ParseQuery(queryString);
|
||||
if (!query.TryGetValue("hmac", out var hmac))
|
||||
return false;
|
||||
if (!query.TryGetValue("timestamp", out var timestampStr) || !long.TryParse(timestampStr, out var timestamp))
|
||||
return false;
|
||||
|
||||
DateTimeOffset date = default;
|
||||
try
|
||||
{
|
||||
date = DateTimeOffset.FromUnixTimeSeconds(timestamp);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!skipLifeTimeCheck && DateTimeOffset.UtcNow - date > TimeSpan.FromHours(1.0))
|
||||
return false;
|
||||
query.Remove("hmac");
|
||||
queryString = queryString.Replace($"hmac={hmac}", "").Replace("&&", "&");
|
||||
var keyBytes = Encoding.UTF8.GetBytes(_credentials.ClientSecret);
|
||||
using (var hmacObj = new HMACSHA256(keyBytes))
|
||||
{
|
||||
var hashBytes = hmacObj.ComputeHash(Encoding.UTF8.GetBytes(queryString));
|
||||
var hashString = BitConverter.ToString(hashBytes).Replace("-","");
|
||||
if (!hashString.Equals(hmac, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
public class ShopifyApiClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _shopUrl;
|
||||
private readonly ShopifyApiClientCredentials _credentials;
|
||||
|
||||
public ShopifyApiClient(
|
||||
HttpClient httpClient,
|
||||
string shopUrl,
|
||||
ShopifyApiClientCredentials credentials)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_shopUrl = shopUrl;
|
||||
_credentials = credentials;
|
||||
|
||||
if (credentials is ShopifyApiClientCredentials.Basic b)
|
||||
{
|
||||
var bearer = $"{b.ApiKey}:{b.ApiPassword}";
|
||||
bearer = NBitcoin.DataEncoders.Encoders.Base64.EncodeData(Encoding.UTF8.GetBytes(bearer));
|
||||
_httpClient.DefaultRequestHeaders.Add("Authorization", "Basic " + bearer);
|
||||
}
|
||||
else if (credentials is ShopifyApiClientCredentials.AccessToken a)
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Add("X-Shopify-Access-Token", a.Token);
|
||||
}
|
||||
else
|
||||
throw new NotSupportedException(credentials.ToString());
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateRequest(HttpMethod method, string action, string relativeUrl = null,
|
||||
string apiVersion = "2024-07")
|
||||
{
|
||||
relativeUrl ??= ($"admin/api/{apiVersion}/" + action);
|
||||
var req = new HttpRequestMessage(method, $"{_shopUrl}/{relativeUrl}");
|
||||
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("{", StringComparison.OrdinalIgnoreCase) && JObject.Parse(strResp)["errors"]?.Value<string>() is string error)
|
||||
{
|
||||
if (error == "Not Found")
|
||||
error = "Shop or Order not found";
|
||||
throw new ShopifyApiException(error);
|
||||
}
|
||||
return strResp;
|
||||
}
|
||||
|
||||
public async Task<CreateWebhookResponse> CreateWebhook(string topic, string address, string format = "json")
|
||||
{
|
||||
var req = CreateRequest(HttpMethod.Post, $"webhooks.json");
|
||||
var payload = new
|
||||
{
|
||||
webhook = new { address, topic, format }
|
||||
};
|
||||
req.Content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
|
||||
var strResp = await SendRequest(req);
|
||||
return JsonConvert.DeserializeObject<CreateWebhookResponse>(strResp);
|
||||
}
|
||||
|
||||
public async Task<List<CreateWebhookResponse>> RetrieveWebhooks()
|
||||
{
|
||||
var req = CreateRequest(HttpMethod.Get, $"webhooks.json");
|
||||
var strResp = await SendRequest(req);
|
||||
return JsonConvert.DeserializeObject<List<CreateWebhookResponse>>(strResp);
|
||||
}
|
||||
|
||||
public async Task<CreateWebhookResponse> RetrieveWebhook(string id)
|
||||
{
|
||||
var req = CreateRequest(HttpMethod.Get, $"webhooks/{id}.json");
|
||||
var strResp = await SendRequest(req);
|
||||
return JsonConvert.DeserializeObject<CreateWebhookResponse>(strResp);
|
||||
}
|
||||
|
||||
public async Task RemoveWebhook(string id)
|
||||
{
|
||||
var req = CreateRequest(HttpMethod.Delete, $"webhooks/{id}.json");
|
||||
await SendRequest(req);
|
||||
}
|
||||
|
||||
public async Task<string[]> CheckScopes()
|
||||
{
|
||||
var req = CreateRequest(HttpMethod.Get, null, "admin/oauth/access_scopes.json");
|
||||
var c = JObject.Parse(await SendRequest(req));
|
||||
return c["access_scopes"].Values<JToken>()
|
||||
.Select(token => token["handle"].Value<string>()).ToArray();
|
||||
}
|
||||
|
||||
public async Task<TransactionsCreateResp> TransactionCreate(long orderId, TransactionsCreateReq txnCreate)
|
||||
{
|
||||
var postJson = JsonConvert.SerializeObject(txnCreate);
|
||||
|
||||
var req = CreateRequest(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<List<ShopifyOrderVm>> RetrieveAllOrders()
|
||||
{
|
||||
var req = CreateRequest(HttpMethod.Get, "orders.json");
|
||||
|
||||
var strResp = await SendRequest(req);
|
||||
|
||||
return JObject.Parse(strResp)["orders"].ToObject<List<ShopifyOrderVm>>();
|
||||
|
||||
}
|
||||
|
||||
|
||||
public async Task<ShopifyOrder> GetOrderByCheckoutToken(string checkoutToken, bool withTransactions = false)
|
||||
{
|
||||
var req = """
|
||||
query getByCheckoutId($query: String!, $includeTxs: Boolean!) {
|
||||
orders(first: 1, query: $query) {
|
||||
edges {
|
||||
node {
|
||||
{OrderData}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""".Replace("{OrderData}", OrderData);
|
||||
var resp = await SendGraphQL(req, new JObject() { ["query"] = $"checkout_token:{checkoutToken}", ["includeTxs"] = withTransactions });
|
||||
return resp["data"]["orders"]["edges"] switch
|
||||
{
|
||||
JArray a when a.Count > 0 => a[0]["node"].ToObject<ShopifyOrder>(JsonSerializer),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
const string OrderData =
|
||||
"""
|
||||
id
|
||||
name
|
||||
totalOutstandingSet {
|
||||
shopMoney {
|
||||
amount,
|
||||
currencyCode
|
||||
}
|
||||
presentmentMoney {
|
||||
amount,
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
transactions @include(if: $includeTxs) {
|
||||
id
|
||||
gateway
|
||||
kind
|
||||
authorizationCode
|
||||
status
|
||||
amountSet {
|
||||
presentmentMoney {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
shopMoney {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
public async Task<ShopifyOrder> GetOrder(long orderId, bool withTransactions = false)
|
||||
{
|
||||
|
||||
// https://shopify.dev/docs/api/admin-graphql/2024-10/queries/order
|
||||
var req = """
|
||||
query getOrderDetails($orderId: ID!, $includeTxs: Boolean!) {
|
||||
order(id: $orderId) {
|
||||
{OrderData}
|
||||
}
|
||||
}
|
||||
""".Replace("{OrderData}", OrderData);
|
||||
|
||||
var resp = await SendGraphQL(req,
|
||||
new JObject()
|
||||
{
|
||||
["orderId"] = ShopifyId.Order(orderId).ToString(),
|
||||
["includeTxs"] = withTransactions
|
||||
});
|
||||
return resp["data"]["order"].ToObject<ShopifyOrder>(JsonSerializer);
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateGraphQLRequest(string req, JObject variables = null)
|
||||
{
|
||||
var jobj = new JObject() { ["query"] = req };
|
||||
if (variables is not null)
|
||||
jobj.Add("variables", variables);
|
||||
return new HttpRequestMessage(HttpMethod.Post, $"{_shopUrl}/admin/api/2024-10/graphql.json")
|
||||
{
|
||||
Content = new StringContent(jobj.ToString(), Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
JsonSerializer JsonSerializer = new JsonSerializer()
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
Converters = { new ShopifyIdJsonConverter(), new StringEnumConverter() }
|
||||
};
|
||||
|
||||
// https://shopify.dev/docs/api/admin-graphql/2024-04/mutations/orderCapture
|
||||
public async Task CaptureOrder(CaptureOrderRequest captureOrder)
|
||||
{
|
||||
var req = """
|
||||
mutation M($input: OrderCaptureInput!){
|
||||
orderCapture(input: $input) {
|
||||
transaction {
|
||||
id
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
JObject respObj = await SendGraphQL(req, new JObject() { ["input"] = JObject.FromObject(captureOrder, JsonSerializer) });
|
||||
|
||||
}
|
||||
public async Task CancelOrder(CancelOrderRequest cancelOrder)
|
||||
{
|
||||
string req = """
|
||||
mutation orderCancel($notifyCustomer: Boolean, $orderId: ID!, $reason: OrderCancelReason!, $refund: Boolean!, $restock: Boolean!, $staffNote: String) {
|
||||
orderCancel(notifyCustomer: $notifyCustomer, orderId: $orderId, reason: $reason, refund: $refund, restock: $restock, staffNote: $staffNote) {
|
||||
orderCancelUserErrors {
|
||||
code
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
JObject respObj = await SendGraphQL(req, JObject.FromObject(cancelOrder, JsonSerializer));
|
||||
var errors = respObj["data"]["orderCancel"]["orderCancelUserErrors"] as JArray;
|
||||
if (errors.Count != 0)
|
||||
throw new ShopifyApiException(errors[0]["message"].Value<string>());
|
||||
}
|
||||
private async Task<JObject> SendGraphQL(string req, JObject variables = null)
|
||||
{
|
||||
var httpReq = CreateGraphQLRequest(req, variables);
|
||||
using var resp = await _httpClient.SendAsync(httpReq);
|
||||
var strResp = await resp.Content.ReadAsStringAsync();
|
||||
resp.EnsureSuccessStatusCode();
|
||||
return JObject.Parse(strResp);
|
||||
}
|
||||
|
||||
public async Task<ShopifyId> CompleteDraftOrder(long orderId)
|
||||
{
|
||||
var req = """
|
||||
mutation M($id: ID!) {
|
||||
draftOrderComplete(id: $id) {
|
||||
draftOrder {
|
||||
order
|
||||
{
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
JObject respObj = await SendGraphQL(req, new JObject() { ["id"] = ShopifyId.DraftOrder(orderId).ToString() });
|
||||
return ShopifyId.Parse(respObj["data"]["draftOrderComplete"]["draftOrder"]["order"]["id"].Value<string>());
|
||||
}
|
||||
public async Task<ShopifyId> DuplicateOrder(long orderId)
|
||||
{
|
||||
var req = """
|
||||
mutation M($id: ID) {
|
||||
draftOrderDuplicate(id: $id) {
|
||||
draftOrder {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
JObject respObj = await SendGraphQL(req, new JObject() { ["id"] = ShopifyId.DraftOrder(orderId).ToString() });
|
||||
return ShopifyId.Parse(respObj["data"]["draftOrderDuplicate"]["draftOrder"]["id"].Value<string>());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public record ShopifyApiClientCredentials
|
||||
{
|
||||
public record Basic (string ApiKey, string ApiPassword) : ShopifyApiClientCredentials;
|
||||
public record AccessToken(string Token) : ShopifyApiClientCredentials;
|
||||
}
|
||||
public record ShopifyAppCredentials(string ClientId, string ClientSecret);
|
||||
}
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
#nullable enable
|
||||
using BTCPayServer.Services.Stores;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.Services
|
||||
{
|
||||
public class ShopifyClientFactory
|
||||
{
|
||||
public ShopifyClientFactory(IHttpClientFactory httpClientFactory, StoreRepository storeRepository)
|
||||
{
|
||||
HttpClientFactory = httpClientFactory;
|
||||
StoreRepository = storeRepository;
|
||||
}
|
||||
|
||||
public IHttpClientFactory HttpClientFactory { get; }
|
||||
public StoreRepository StoreRepository { get; }
|
||||
|
||||
public async Task<ShopifyApiClient?> CreateAPIClient(string storeId)
|
||||
{
|
||||
var settings = await GetSettings(storeId);
|
||||
if (settings is { ShopUrl: string shopUrl, AccessToken: string accessToken })
|
||||
return new ShopifyApiClient(HttpClientFactory.CreateClient("SHOPIFY_API_CLIENT"), shopUrl, new ShopifyApiClientCredentials.AccessToken(accessToken));
|
||||
return null;
|
||||
}
|
||||
public async Task<ShopifyAppClient?> CreateAppClient(string storeId)
|
||||
{
|
||||
var settings = await GetSettings(storeId);
|
||||
if (settings is { App: { ClientId: string id, ClientSecret: string secret } })
|
||||
return new ShopifyAppClient(HttpClientFactory.CreateClient("SHOPIFY_APP_CLIENT"), new ShopifyAppCredentials(id, secret));
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<ShopifyStoreSettings?> GetSettings(string storeId)
|
||||
{
|
||||
return await StoreRepository.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,205 @@
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Plugins.Shopify.ApiModels;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.Services;
|
||||
|
||||
public class ShopifyHostedService : EventHostedServiceBase
|
||||
{
|
||||
private readonly InvoiceRepository _invoiceRepository;
|
||||
private readonly ShopifyClientFactory shopifyClientFactory;
|
||||
|
||||
public ShopifyHostedService(EventAggregator eventAggregator,
|
||||
InvoiceRepository invoiceRepository,
|
||||
ShopifyClientFactory shopifyClientFactory,
|
||||
Logs logs) : base(eventAggregator, logs)
|
||||
{
|
||||
_invoiceRepository = invoiceRepository;
|
||||
this.shopifyClientFactory = shopifyClientFactory;
|
||||
}
|
||||
|
||||
protected override void SubscribeToEvents()
|
||||
{
|
||||
Subscribe<InvoiceEvent>();
|
||||
base.SubscribeToEvents();
|
||||
}
|
||||
|
||||
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
||||
{
|
||||
if (evt is InvoiceEvent invoiceEvent && new[]
|
||||
{
|
||||
InvoiceEvent.MarkedCompleted,
|
||||
InvoiceEvent.MarkedInvalid,
|
||||
InvoiceEvent.Expired,
|
||||
InvoiceEvent.Confirmed,
|
||||
InvoiceEvent.Completed
|
||||
}.Contains(invoiceEvent.Name))
|
||||
{
|
||||
var invoice = invoiceEvent.Invoice;
|
||||
if (invoice.GetShopifyOrderId() is long shopifyOrderId)
|
||||
{
|
||||
bool? success = invoice.Status switch
|
||||
{
|
||||
InvoiceStatus.Processing or InvoiceStatus.Settled => true,
|
||||
InvoiceStatus.Invalid or InvoiceStatus.Expired => false,
|
||||
_ => (bool?)null
|
||||
};
|
||||
if (success.HasValue)
|
||||
await RegisterTransaction(invoice, shopifyOrderId, success.Value);
|
||||
}
|
||||
}
|
||||
await base.ProcessEvent(evt, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task RegisterTransaction(InvoiceEntity invoice, long shopifyOrderId, bool success)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await Process(shopifyOrderId, invoice, success);
|
||||
if (resp != null)
|
||||
{
|
||||
await _invoiceRepository.AddInvoiceLogs(invoice.Id, resp);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.PayServer.LogError(ex,
|
||||
$"Shopify error while trying to register order transaction. " +
|
||||
$"Triggered by invoiceId: {invoice.Id}, Shopify orderId: {shopifyOrderId}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] _keywords = new[] { "bitcoin", "btc", "btcpayserver", "btcpay server" };
|
||||
|
||||
public async Task<InvoiceLogs> Process(long shopifyOrderId, InvoiceEntity invoice, bool success)
|
||||
{
|
||||
var logs = new InvoiceLogs();
|
||||
var client = await shopifyClientFactory.CreateAPIClient(invoice.StoreId);
|
||||
if (client is null)
|
||||
return logs;
|
||||
if (await client.GetOrder(shopifyOrderId, true) is not { } order)
|
||||
return logs;
|
||||
|
||||
var existingShopifyOrderTransactions = order.Transactions;
|
||||
//if there isn't a record for btcpay payment gateway, abort
|
||||
var baseParentTransaction = existingShopifyOrderTransactions.FirstOrDefault(holder =>
|
||||
_keywords.Any(a => holder.Gateway.Contains(a, StringComparison.InvariantCultureIgnoreCase)));
|
||||
if (baseParentTransaction is null)
|
||||
{
|
||||
logs.Write("Couldn't find the order on Shopify.", InvoiceEventData.EventSeverity.Error);
|
||||
return logs;
|
||||
}
|
||||
//technically, this exploit should not be possible as we use internal invoice tags to verify that the invoice was created by our controlled, dedicated endpoint.
|
||||
if (!invoice.Currency.Equals(baseParentTransaction.AmountSet.PresentmentMoney.CurrencyCode, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// because of parent_id present, currency will always be the one from parent transaction
|
||||
// malicious attacker could potentially exploit this by creating invoice
|
||||
// in different currency and paying that one, registering order on Shopify as paid
|
||||
// so if currency is supplied and is different from parent transaction currency we just won't register
|
||||
logs.Write("Currency mismatch on Shopify.", InvoiceEventData.EventSeverity.Error);
|
||||
return logs;
|
||||
}
|
||||
|
||||
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.AuthorizationCode == invoice.Id);
|
||||
|
||||
//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";
|
||||
logs.Write(
|
||||
"A transaction was previously recorded against the Shopify order. Creating a void transaction.",
|
||||
InvoiceEventData.EventSeverity.Warning);
|
||||
}
|
||||
else if (!success)
|
||||
{
|
||||
kind = "void";
|
||||
status = "success";
|
||||
logs.Write("Attempting to void the payment on Shopify order due to failure in payment.",
|
||||
InvoiceEventData.EventSeverity.Warning);
|
||||
}
|
||||
//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)
|
||||
{
|
||||
logs.Write("A transaction was previously recorded against the Shopify order. Skipping.",
|
||||
InvoiceEventData.EventSeverity.Warning);
|
||||
return logs;
|
||||
}
|
||||
|
||||
var createTransaction = new BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models.TransactionsCreateReq
|
||||
{
|
||||
transaction = new BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models.TransactionsCreateReq.DataHolder
|
||||
{
|
||||
parent_id = parentId.Id,
|
||||
currency = invoice.Currency,
|
||||
amount = invoice.PaidAmount.Net.ToString(CultureInfo.InvariantCulture),
|
||||
kind = kind,
|
||||
gateway = "BTCPayServer",
|
||||
source = "external",
|
||||
authorization = invoice.Id,
|
||||
status = status
|
||||
}
|
||||
};
|
||||
var createResp = await client.TransactionCreate(shopifyOrderId, createTransaction);
|
||||
if (createResp.transaction is null)
|
||||
{
|
||||
logs.Write("Failed to register the transaction on Shopify.", InvoiceEventData.EventSeverity.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
logs.Write(
|
||||
$"Successfully registered the transaction on Shopify. tx status:{createResp.transaction.status}, kind: {createResp.transaction.kind}, order id:{createResp.transaction.order_id}",
|
||||
InvoiceEventData.EventSeverity.Info);
|
||||
}
|
||||
if (!success)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.CancelOrder(new()
|
||||
{
|
||||
OrderId = order.Id,
|
||||
NotifyCustomer = false,
|
||||
Reason = OrderCancelReason.DECLINED,
|
||||
Restock = true,
|
||||
StaffNote = $"BTCPay Invoice {invoice.Id} expired or invalid"
|
||||
});
|
||||
logs.Write("Cancelling the Shopify order.", InvoiceEventData.EventSeverity.Warning);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logs.Write($"Failed to cancel the Shopify order. {e.Message}",
|
||||
InvoiceEventData.EventSeverity.Error);
|
||||
}
|
||||
}
|
||||
return logs;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin
|
||||
{
|
||||
public class ShopifyApiException : Exception
|
||||
{
|
||||
public ShopifyApiException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
#nullable enable
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin
|
||||
{
|
||||
public class ShopifyStoreSettings
|
||||
{
|
||||
public class AppCreds
|
||||
{
|
||||
public string? ClientId { get; set; }
|
||||
public string? ClientSecret { get; set; }
|
||||
}
|
||||
public AppCreds? App { get; set; }
|
||||
public string? ShopUrl { get; set; }
|
||||
public string? AccessToken { get; set; }
|
||||
public const string SettingsName = "ShopifyPluginSettings";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
using BTCPayServer.Models;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
|
||||
|
||||
public class BaseShopifyPublicViewModel
|
||||
{
|
||||
// store properties
|
||||
public string StoreId { get; set; }
|
||||
public string StoreName { get; set; }
|
||||
public StoreBrandingViewModel StoreBranding { get; set; }
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models
|
||||
{
|
||||
public class OrdersCreateWebhook
|
||||
{
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
|
||||
|
||||
public enum OrderCancelReason
|
||||
{
|
||||
CUSTOMER,
|
||||
DECLINED,
|
||||
FRAUD,
|
||||
INVENTORY,
|
||||
OTHER,
|
||||
STAFF
|
||||
}
|
||||
public class CancelOrderRequest
|
||||
{
|
||||
public bool NotifyCustomer { get; set; }
|
||||
public ShopifyId OrderId { get; set; }
|
||||
public OrderCancelReason Reason { get; set; }
|
||||
public bool Refund { get; set; }
|
||||
public bool Restock { get; set; }
|
||||
public string StaffNote { get; set; }
|
||||
}
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
|
||||
|
||||
public class CaptureOrderRequest
|
||||
{
|
||||
[JsonConverter(typeof(BTCPayServer.JsonConverters.NumericStringJsonConverter))]
|
||||
public decimal Amount { get; set; }
|
||||
public string Currency { get; set; }
|
||||
public ShopifyId Id { get; set; }
|
||||
public ShopifyId ParentTransactionId { get; set; }
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
|
||||
public class ShopifyOrderViewModel : BaseShopifyPublicViewModel
|
||||
{
|
||||
public string OrderId { get; set; }
|
||||
public string InvoiceId { get; set; }
|
||||
public string ShopName { get; set; }
|
||||
public string BTCPayServerUrl { get; set; }
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
|
||||
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,15 @@
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
|
||||
public class AccessTokenResponse
|
||||
{
|
||||
[JsonProperty("access_token")] public string AccessToken { get; set; }
|
||||
[JsonProperty("scope")] public string Scope { get; set; }
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
|
||||
public class CountResponse
|
||||
{
|
||||
[JsonProperty("count")]
|
||||
public long Count { get; set; }
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
|
||||
public class CreateWebhookResponse
|
||||
{
|
||||
[JsonProperty("webhook")] public Webhook Webhook { get; set; }
|
||||
}
|
||||
|
||||
public class Webhook
|
||||
{
|
||||
[JsonProperty("id")] public long 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,66 @@
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
|
||||
public record ShopifyId(string Type, long Id)
|
||||
{
|
||||
public static ShopifyId Order(long Id) => new("Order", Id);
|
||||
public static ShopifyId DraftOrder(long Id) => new("DraftOrder", Id);
|
||||
public static bool TryParse(string str, [MaybeNullWhen(false)] out ShopifyId id)
|
||||
{
|
||||
id = null;
|
||||
if (str == null)
|
||||
return false;
|
||||
if (!str.StartsWith("gid://shopify/"))
|
||||
return false;
|
||||
var parts = str.Substring("gid://shopify/".Length).Split('/');
|
||||
if (parts.Length != 2)
|
||||
return false;
|
||||
if (!long.TryParse(parts[1], out var lid))
|
||||
return false;
|
||||
id = new ShopifyId(parts[0], lid);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static ShopifyId Parse(string str)
|
||||
{
|
||||
if (!TryParse(str, out var id))
|
||||
throw new FormatException("Invalid ShopifyId");
|
||||
return id;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"gid://shopify/{Type}/{Id}";
|
||||
}
|
||||
}
|
||||
public class OrderTransaction
|
||||
{
|
||||
public ShopifyId Id { get; set; }
|
||||
public string Gateway { get; set; }
|
||||
public string Kind { get; set; }
|
||||
public string AuthorizationCode { get; set; }
|
||||
public string Status { get; set; }
|
||||
public ShopifyMoneyBag AmountSet { get; set; }
|
||||
}
|
||||
public class ShopifyOrder
|
||||
{
|
||||
public ShopifyId Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public ShopifyMoneyBag TotalOutstandingSet { get; set; }
|
||||
public OrderTransaction[] Transactions { get; set; }
|
||||
}
|
||||
public class ShopifyMoneyBag
|
||||
{
|
||||
public ShopifyMoney PresentmentMoney { get; set; }
|
||||
public ShopifyMoney ShopMoney { get; set; }
|
||||
}
|
||||
public class ShopifyMoney
|
||||
{
|
||||
public string CurrencyCode { get; set; }
|
||||
[JsonConverter(typeof(BTCPayServer.JsonConverters.NumericStringJsonConverter))]
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
|
||||
public class ShopifyOrderVm
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string AdminGraphqlApiId { get; set; }
|
||||
public long AppId { get; set; }
|
||||
public string BrowserIp { get; set; }
|
||||
public bool BuyerAcceptsMarketing { get; set; }
|
||||
public string CancelReason { get; set; }
|
||||
public DateTime? CancelledAt { get; set; }
|
||||
public string CartToken { get; set; }
|
||||
public long CheckoutId { get; set; }
|
||||
public string CheckoutToken { get; set; }
|
||||
public ClientDetails ClientDetails { get; set; }
|
||||
public string ConfirmationNumber { get; set; }
|
||||
public bool Confirmed { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public string Currency { get; set; }
|
||||
public string CurrentSubtotalPrice { get; set; }
|
||||
public PriceSet CurrentSubtotalPriceSet { get; set; }
|
||||
public string CurrentTotalPrice { get; set; }
|
||||
public PriceSet CurrentTotalPriceSet { get; set; }
|
||||
public string FinancialStatus { get; set; }
|
||||
public FulfillmentStatus FulfillmentStatus { get; set; }
|
||||
public string LandingSite { get; set; }
|
||||
public string Name { get; set; }
|
||||
public int Number { get; set; }
|
||||
public string OrderNumber { get; set; }
|
||||
public List<LineItem> LineItems { get; set; }
|
||||
public List<ShippingLine> ShippingLines { get; set; }
|
||||
public BillingAddress BillingAddress { get; set; }
|
||||
public ShippingAddress ShippingAddress { get; set; }
|
||||
public Customer Customer { get; set; }
|
||||
}
|
||||
|
||||
public class ShopifyOrderResponseViewModel
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string CartToken { get; set; }
|
||||
public long CheckoutId { get; set; }
|
||||
public string CheckoutToken { get; set; }
|
||||
public string ConfirmationNumber { get; set; }
|
||||
public bool Confirmed { get; set; }
|
||||
public string Currency { get; set; }
|
||||
public string CurrentSubtotalPrice { get; set; }
|
||||
public string CurrentTotalPrice { get; set; }
|
||||
public string FinancialStatus { get; set; }
|
||||
public int Number { get; set; }
|
||||
public string OrderNumber { get; set; }
|
||||
}
|
||||
|
||||
|
||||
public class ClientDetails
|
||||
{
|
||||
public string AcceptLanguage { get; set; }
|
||||
public string BrowserIp { get; set; }
|
||||
public string UserAgent { get; set; }
|
||||
}
|
||||
|
||||
public class PriceSet
|
||||
{
|
||||
public Money ShopMoney { get; set; }
|
||||
public Money PresentmentMoney { get; set; }
|
||||
}
|
||||
|
||||
public class Money
|
||||
{
|
||||
public string Amount { get; set; }
|
||||
public string CurrencyCode { get; set; }
|
||||
}
|
||||
|
||||
public class TotalDiscountSet
|
||||
{
|
||||
public Money ShopMoney { get; set; }
|
||||
public Money PresentmentMoney { get; set; }
|
||||
}
|
||||
|
||||
public class FulfillmentStatus
|
||||
{
|
||||
}
|
||||
|
||||
public class LineItem
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string AdminGraphqlApiId { get; set; }
|
||||
public int CurrentQuantity { get; set; }
|
||||
public int FulfillableQuantity { get; set; }
|
||||
public string FulfillmentService { get; set; }
|
||||
public string FulfillmentStatus { get; set; }
|
||||
public bool GiftCard { get; set; }
|
||||
public int Grams { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Price { get; set; }
|
||||
public PriceSet PriceSet { get; set; }
|
||||
public bool ProductExists { get; set; }
|
||||
public long ProductId { get; set; }
|
||||
public int Quantity { get; set; }
|
||||
public bool RequiresShipping { get; set; }
|
||||
public bool Taxable { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string TotalDiscount { get; set; }
|
||||
public TotalDiscountSet TotalDiscountSet { get; set; }
|
||||
public long VariantId { get; set; }
|
||||
public string VariantInventoryManagement { get; set; }
|
||||
public string Vendor { get; set; }
|
||||
public List<TaxLine> TaxLines { get; set; }
|
||||
public List<Duty> Duties { get; set; }
|
||||
public List<DiscountAllocation> DiscountAllocations { get; set; }
|
||||
}
|
||||
|
||||
public class ShippingLine
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string CarrierIdentifier { get; set; }
|
||||
public string Code { get; set; }
|
||||
public PriceSet PriceSet { get; set; }
|
||||
}
|
||||
|
||||
public class BillingAddress
|
||||
{
|
||||
public string Country { get; set; }
|
||||
public string CountryCode { get; set; }
|
||||
}
|
||||
|
||||
public class ShippingAddress
|
||||
{
|
||||
public string Country { get; set; }
|
||||
public string CountryCode { get; set; }
|
||||
}
|
||||
|
||||
public class Customer
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public string State { get; set; }
|
||||
public bool VerifiedEmail { get; set; }
|
||||
public BillingAddress DefaultAddress { get; set; }
|
||||
}
|
||||
|
||||
public class TaxLine { }
|
||||
|
||||
public class Duty { }
|
||||
|
||||
public class DiscountAllocation { }
|
||||
@ -0,0 +1,47 @@
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
|
||||
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,28 @@
|
||||
using System;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
|
||||
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; }
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
|
||||
public class TransactionsCreateResp
|
||||
{
|
||||
public TransactionDataHolder transaction { get; set; }
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
|
||||
|
||||
public class TransactionsListResp
|
||||
{
|
||||
public List<TransactionDataHolder> transactions { get; set; }
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models
|
||||
{
|
||||
public class ShopifySettingsViewModel
|
||||
{
|
||||
public string ClientId { get; set; }
|
||||
public string ClientSecret { get; set; }
|
||||
public string ShopUrl { get; set; }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
@using BTCPayServer.Abstractions.Contracts
|
||||
@using BTCPayServer.Abstractions.Extensions
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@inject IScopeProvider ScopeProvider
|
||||
@{
|
||||
var storeId = ScopeProvider.GetCurrentStoreId();
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(storeId))
|
||||
{
|
||||
<li class="nav-item">
|
||||
<a asp-controller="UIShopify" asp-action="Settings" asp-route-storeId="@storeId" class="nav-link @ViewData.ActivePageClass("Shopify")">
|
||||
<svg role="img" class="icon icon-plugin" viewBox="0 0 32 32" fill="none"><path transform="scale(.7) translate(5, 5)" d="m20.45 31.97 9.62-2.08-3.5-23.64c-.03-.16-.15-.26-.28-.26l-2.57-.18s-1.7-1.7-1.92-1.88a.41.41 0 0 0-.16-.1l-1.22 28.14zm-4.83-16.9s-1.09-.56-2.37-.56c-1.93 0-2 1.2-2 1.52 0 1.64 4.31 2.29 4.31 6.17 0 3.06-1.92 5.01-4.54 5.01-3.14 0-4.72-1.95-4.72-1.95l.86-2.78s1.66 1.42 3.04 1.42c.9 0 1.3-.72 1.3-1.24 0-2.16-3.54-2.26-3.54-5.81-.04-2.98 2.1-5.9 6.44-5.9 1.68 0 2.5.49 2.5.49l-1.26 3.62zM14.9 1.1c.17 0 .36.06.53.19-1.31.62-2.75 2.18-3.34 5.32-.88.28-1.73.54-2.52.77.69-2.38 2.36-6.26 5.33-6.26zm1.64 3.94v.18l-3.2.98c.63-2.37 1.79-3.53 2.79-3.96.26.67.41 1.57.41 2.8zm.72-2.98c.92.1 1.52 1.15 1.9 2.34-.46.15-.98.3-1.54.49v-.34c0-1-.13-1.82-.36-2.5zm3.99 1.72-.1.03c-.03 0-.39.1-.96.28-.56-1.65-1.56-3.16-3.34-3.16h-.16C16.2.28 15.56 0 15.02 0 10.88 0 8.9 5.17 8.28 7.8c-1.6.48-2.75.84-2.88.9-.9.28-.93.3-1.03 1.15-.1.62-2.44 18.75-2.44 18.75L20.01 32z" fill="currentColor" /></svg>
|
||||
<span>Shopify v2</span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
@using BTCPayServer.Abstractions.Extensions
|
||||
@using BTCPayServer.Abstractions.Services
|
||||
@using BTCPayServer.Client
|
||||
@using BTCPayServer.Controllers
|
||||
@using BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models
|
||||
@using Microsoft.AspNetCore.Routing
|
||||
@model ShopifySettingsViewModel
|
||||
@{
|
||||
ViewData.SetActivePage("Shopify", "Update Shopify Plugin Settings", "Shopify");
|
||||
Layout = "_Layout";
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@addTagHelper *, BTCPayServer
|
||||
@addTagHelper *, BTCPayServer.Abstractions
|
||||
149
btcpayserver-shopify-plugin.sln
Normal file
149
btcpayserver-shopify-plugin.sln
Normal file
@ -0,0 +1,149 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plugins", "Plugins", "{07D57EEB-2F50-60C4-C011-FE4FA775C9A8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.ShopifyPlugin", "Plugins\BTCPayServer.Plugins.ShopifyPlugin\BTCPayServer.Plugins.ShopifyPlugin.csproj", "{5BF1CEC8-5E4F-47A3-B7FF-F87A458F144F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer", "submodules\btcpayserver\BTCPayServer\BTCPayServer.csproj", "{1951AC3E-2838-2566-0FC8-A5E19B690DF9}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "modules", "modules", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Common", "submodules\btcpayserver\BTCPayServer.Common\BTCPayServer.Common.csproj", "{04E63950-9F2A-B30A-0227-9370252FD438}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Data", "submodules\btcpayserver\BTCPayServer.Data\BTCPayServer.Data.csproj", "{B50071CB-27D3-4E3A-FA18-073DEFF1989E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Abstractions", "submodules\btcpayserver\BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj", "{36BC813A-4C0C-DA28-0834-A187B639619F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Client", "submodules\btcpayserver\BTCPayServer.Client\BTCPayServer.Client.csproj", "{8A900ED8-DD0F-D0C3-4A60-BA462F22C181}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Rating", "submodules\btcpayserver\BTCPayServer.Rating\BTCPayServer.Rating.csproj", "{DB213CC9-4075-7CB4-895F-F67A2975D1F1}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.ShopifyPlugin.Tests", "Plugins\BTCPayServer.Plugins.ShopifyPlugin.Tests\BTCPayServer.Plugins.ShopifyPlugin.Tests.csproj", "{D2725B45-283C-9DE3-B292-7FEC03E5E11F}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{5BF1CEC8-5E4F-47A3-B7FF-F87A458F144F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5BF1CEC8-5E4F-47A3-B7FF-F87A458F144F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5BF1CEC8-5E4F-47A3-B7FF-F87A458F144F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5BF1CEC8-5E4F-47A3-B7FF-F87A458F144F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5BF1CEC8-5E4F-47A3-B7FF-F87A458F144F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5BF1CEC8-5E4F-47A3-B7FF-F87A458F144F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5BF1CEC8-5E4F-47A3-B7FF-F87A458F144F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5BF1CEC8-5E4F-47A3-B7FF-F87A458F144F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5BF1CEC8-5E4F-47A3-B7FF-F87A458F144F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5BF1CEC8-5E4F-47A3-B7FF-F87A458F144F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5BF1CEC8-5E4F-47A3-B7FF-F87A458F144F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5BF1CEC8-5E4F-47A3-B7FF-F87A458F144F}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1951AC3E-2838-2566-0FC8-A5E19B690DF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1951AC3E-2838-2566-0FC8-A5E19B690DF9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1951AC3E-2838-2566-0FC8-A5E19B690DF9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1951AC3E-2838-2566-0FC8-A5E19B690DF9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1951AC3E-2838-2566-0FC8-A5E19B690DF9}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1951AC3E-2838-2566-0FC8-A5E19B690DF9}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1951AC3E-2838-2566-0FC8-A5E19B690DF9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1951AC3E-2838-2566-0FC8-A5E19B690DF9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1951AC3E-2838-2566-0FC8-A5E19B690DF9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1951AC3E-2838-2566-0FC8-A5E19B690DF9}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1951AC3E-2838-2566-0FC8-A5E19B690DF9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1951AC3E-2838-2566-0FC8-A5E19B690DF9}.Release|x86.Build.0 = Release|Any CPU
|
||||
{04E63950-9F2A-B30A-0227-9370252FD438}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{04E63950-9F2A-B30A-0227-9370252FD438}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{04E63950-9F2A-B30A-0227-9370252FD438}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{04E63950-9F2A-B30A-0227-9370252FD438}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{04E63950-9F2A-B30A-0227-9370252FD438}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{04E63950-9F2A-B30A-0227-9370252FD438}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{04E63950-9F2A-B30A-0227-9370252FD438}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{04E63950-9F2A-B30A-0227-9370252FD438}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{04E63950-9F2A-B30A-0227-9370252FD438}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{04E63950-9F2A-B30A-0227-9370252FD438}.Release|x64.Build.0 = Release|Any CPU
|
||||
{04E63950-9F2A-B30A-0227-9370252FD438}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{04E63950-9F2A-B30A-0227-9370252FD438}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B50071CB-27D3-4E3A-FA18-073DEFF1989E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B50071CB-27D3-4E3A-FA18-073DEFF1989E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B50071CB-27D3-4E3A-FA18-073DEFF1989E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B50071CB-27D3-4E3A-FA18-073DEFF1989E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B50071CB-27D3-4E3A-FA18-073DEFF1989E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B50071CB-27D3-4E3A-FA18-073DEFF1989E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B50071CB-27D3-4E3A-FA18-073DEFF1989E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B50071CB-27D3-4E3A-FA18-073DEFF1989E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B50071CB-27D3-4E3A-FA18-073DEFF1989E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B50071CB-27D3-4E3A-FA18-073DEFF1989E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B50071CB-27D3-4E3A-FA18-073DEFF1989E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B50071CB-27D3-4E3A-FA18-073DEFF1989E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{36BC813A-4C0C-DA28-0834-A187B639619F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{36BC813A-4C0C-DA28-0834-A187B639619F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{36BC813A-4C0C-DA28-0834-A187B639619F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{36BC813A-4C0C-DA28-0834-A187B639619F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{36BC813A-4C0C-DA28-0834-A187B639619F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{36BC813A-4C0C-DA28-0834-A187B639619F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{36BC813A-4C0C-DA28-0834-A187B639619F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{36BC813A-4C0C-DA28-0834-A187B639619F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{36BC813A-4C0C-DA28-0834-A187B639619F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{36BC813A-4C0C-DA28-0834-A187B639619F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{36BC813A-4C0C-DA28-0834-A187B639619F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{36BC813A-4C0C-DA28-0834-A187B639619F}.Release|x86.Build.0 = Release|Any CPU
|
||||
{8A900ED8-DD0F-D0C3-4A60-BA462F22C181}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8A900ED8-DD0F-D0C3-4A60-BA462F22C181}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8A900ED8-DD0F-D0C3-4A60-BA462F22C181}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{8A900ED8-DD0F-D0C3-4A60-BA462F22C181}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{8A900ED8-DD0F-D0C3-4A60-BA462F22C181}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{8A900ED8-DD0F-D0C3-4A60-BA462F22C181}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{8A900ED8-DD0F-D0C3-4A60-BA462F22C181}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8A900ED8-DD0F-D0C3-4A60-BA462F22C181}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8A900ED8-DD0F-D0C3-4A60-BA462F22C181}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{8A900ED8-DD0F-D0C3-4A60-BA462F22C181}.Release|x64.Build.0 = Release|Any CPU
|
||||
{8A900ED8-DD0F-D0C3-4A60-BA462F22C181}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{8A900ED8-DD0F-D0C3-4A60-BA462F22C181}.Release|x86.Build.0 = Release|Any CPU
|
||||
{DB213CC9-4075-7CB4-895F-F67A2975D1F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DB213CC9-4075-7CB4-895F-F67A2975D1F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DB213CC9-4075-7CB4-895F-F67A2975D1F1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DB213CC9-4075-7CB4-895F-F67A2975D1F1}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DB213CC9-4075-7CB4-895F-F67A2975D1F1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DB213CC9-4075-7CB4-895F-F67A2975D1F1}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DB213CC9-4075-7CB4-895F-F67A2975D1F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DB213CC9-4075-7CB4-895F-F67A2975D1F1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DB213CC9-4075-7CB4-895F-F67A2975D1F1}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DB213CC9-4075-7CB4-895F-F67A2975D1F1}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DB213CC9-4075-7CB4-895F-F67A2975D1F1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DB213CC9-4075-7CB4-895F-F67A2975D1F1}.Release|x86.Build.0 = Release|Any CPU
|
||||
{D2725B45-283C-9DE3-B292-7FEC03E5E11F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D2725B45-283C-9DE3-B292-7FEC03E5E11F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D2725B45-283C-9DE3-B292-7FEC03E5E11F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{D2725B45-283C-9DE3-B292-7FEC03E5E11F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{D2725B45-283C-9DE3-B292-7FEC03E5E11F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{D2725B45-283C-9DE3-B292-7FEC03E5E11F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{D2725B45-283C-9DE3-B292-7FEC03E5E11F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D2725B45-283C-9DE3-B292-7FEC03E5E11F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D2725B45-283C-9DE3-B292-7FEC03E5E11F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{D2725B45-283C-9DE3-B292-7FEC03E5E11F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{D2725B45-283C-9DE3-B292-7FEC03E5E11F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{D2725B45-283C-9DE3-B292-7FEC03E5E11F}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{5BF1CEC8-5E4F-47A3-B7FF-F87A458F144F} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
|
||||
{1951AC3E-2838-2566-0FC8-A5E19B690DF9} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||
{04E63950-9F2A-B30A-0227-9370252FD438} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||
{B50071CB-27D3-4E3A-FA18-073DEFF1989E} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||
{36BC813A-4C0C-DA28-0834-A187B639619F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||
{8A900ED8-DD0F-D0C3-4A60-BA462F22C181} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||
{DB213CC9-4075-7CB4-895F-F67A2975D1F1} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||
{D2725B45-283C-9DE3-B292-7FEC03E5E11F} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {445D4B89-357F-4B3D-BA2B-1A8E9B4154B8}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
1
submodules/btcpayserver
Submodule
1
submodules/btcpayserver
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 1c8ded93629e5764004faea269c1ebce0a4a1dd1
|
||||
Loading…
Reference in New Issue
Block a user