Init Commit

This commit is contained in:
nicolas.dorier 2025-02-17 17:23:54 +09:00
commit b3f606546e
No known key found for this signature in database
GPG Key ID: 6618763EF09186FE
36 changed files with 1933 additions and 0 deletions

25
.dockerignore Normal file
View 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
View File

@ -0,0 +1,6 @@
**/bin/**/*
**/obj
.idea
Plugins/packed
.vs/
monero_wallet/

6
.gitmodules vendored Normal file
View 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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
using System;
namespace BTCPayServer.Plugins.ShopifyPlugin
{
public class ShopifyApiException : Exception
{
public ShopifyApiException(string message) : base(message)
{
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
using Newtonsoft.Json;
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
public class CountResponse
{
[JsonProperty("count")]
public long Count { get; set; }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
public class TransactionsCreateResp
{
public TransactionDataHolder transaction { get; set; }
}

View File

@ -0,0 +1,8 @@
using System.Collections.Generic;
namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models;
public class TransactionsListResp
{
public List<TransactionDataHolder> transactions { 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.Plugins.ShopifyPlugin.ViewModels.Models
{
public class ShopifySettingsViewModel
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string ShopUrl { get; set; }
}
}

View File

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

View File

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

View File

@ -0,0 +1,3 @@
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, BTCPayServer
@addTagHelper *, BTCPayServer.Abstractions

View 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

@ -0,0 +1 @@
Subproject commit 1c8ded93629e5764004faea269c1ebce0a4a1dd1