commit b3f606546e1998c9b19d585e913d87ea638e5d8b Author: nicolas.dorier Date: Mon Feb 17 17:23:54 2025 +0900 Init Commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd967fc --- /dev/null +++ b/.dockerignore @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6db436f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +**/bin/**/* +**/obj +.idea +Plugins/packed +.vs/ +monero_wallet/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..90aa620 --- /dev/null +++ b/.gitmodules @@ -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 diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin.Tests/BTCPayServer.Plugins.ShopifyPlugin.Tests.csproj b/Plugins/BTCPayServer.Plugins.ShopifyPlugin.Tests/BTCPayServer.Plugins.ShopifyPlugin.Tests.csproj new file mode 100644 index 0000000..fa9a91f --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin.Tests/BTCPayServer.Plugins.ShopifyPlugin.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + b84e3bdc-081d-476c-828e-df82abbfd9c7 + + + + + + + + + + + + + + + + + + + diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin.Tests/UnitTest1.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin.Tests/UnitTest1.cs new file mode 100644 index 0000000..aaf035f --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin.Tests/UnitTest1.cs @@ -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(() => 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(); + 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)); + } + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/BTCPayServer.Plugins.ShopifyPlugin.csproj b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/BTCPayServer.Plugins.ShopifyPlugin.csproj new file mode 100644 index 0000000..afdaa7f --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/BTCPayServer.Plugins.ShopifyPlugin.csproj @@ -0,0 +1,32 @@ + + + net8.0 + + + + + BTCPay Server Shopify Plugin v2 + BTCPay server integration with Shopify. + 1.0.1 + + + + + true + false + true + + + + + + StaticWebAssetsEnabled=false + false + runtime;native;build;buildTransitive;contentFiles + + + + + + + diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Controllers/UIShopifyController.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Controllers/UIShopifyController.cs new file mode 100644 index 0000000..7874408 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Controllers/UIShopifyController.cs @@ -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 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(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 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(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(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 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() { 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 Webhook(string storeId) + { + var settings = await _storeRepo.GetSettingAsync(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(requestBody)!; + // checkoutTokens.Add(new(storeId, (string)order.checkout_token), (long)order.id); + //} + + return Ok(); + } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Extensions.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Extensions.cs new file mode 100644 index 0000000..b33f663 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Extensions.cs @@ -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(); + + } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/JsonConverters/ShopifyIdJsonConverter.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/JsonConverters/ShopifyIdJsonConverter.cs new file mode 100644 index 0000000..42c512d --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/JsonConverters/ShopifyIdJsonConverter.cs @@ -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 + { + 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()); + } + } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Plugin.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Plugin.cs new file mode 100644 index 0000000..5aa50f1 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Plugin.cs @@ -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(); + services.AddHostedService(); + } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Services/ShopifyApiClient.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Services/ShopifyApiClient.cs new file mode 100644 index 0000000..73da9d8 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Services/ShopifyApiClient.cs @@ -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; + } + /// + /// Validate a session token + /// + /// + /// + /// storeUrl + 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 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(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 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() is string error) + { + if (error == "Not Found") + error = "Shop or Order not found"; + throw new ShopifyApiException(error); + } + return strResp; + } + + public async Task 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(strResp); + } + + public async Task> RetrieveWebhooks() + { + var req = CreateRequest(HttpMethod.Get, $"webhooks.json"); + var strResp = await SendRequest(req); + return JsonConvert.DeserializeObject>(strResp); + } + + public async Task RetrieveWebhook(string id) + { + var req = CreateRequest(HttpMethod.Get, $"webhooks/{id}.json"); + var strResp = await SendRequest(req); + return JsonConvert.DeserializeObject(strResp); + } + + public async Task RemoveWebhook(string id) + { + var req = CreateRequest(HttpMethod.Delete, $"webhooks/{id}.json"); + await SendRequest(req); + } + + public async Task CheckScopes() + { + var req = CreateRequest(HttpMethod.Get, null, "admin/oauth/access_scopes.json"); + var c = JObject.Parse(await SendRequest(req)); + return c["access_scopes"].Values() + .Select(token => token["handle"].Value()).ToArray(); + } + + public async Task 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(strResp); + } + + public async Task> RetrieveAllOrders() + { + var req = CreateRequest(HttpMethod.Get, "orders.json"); + + var strResp = await SendRequest(req); + + return JObject.Parse(strResp)["orders"].ToObject>(); + + } + + + public async Task 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(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 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(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()); + } + private async Task 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 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()); + } + public async Task 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()); + } + } + + + public record ShopifyApiClientCredentials + { + public record Basic (string ApiKey, string ApiPassword) : ShopifyApiClientCredentials; + public record AccessToken(string Token) : ShopifyApiClientCredentials; + } + public record ShopifyAppCredentials(string ClientId, string ClientSecret); +} + diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Services/ShopifyClientFactory.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Services/ShopifyClientFactory.cs new file mode 100644 index 0000000..ff1fbf5 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Services/ShopifyClientFactory.cs @@ -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 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 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 GetSettings(string storeId) + { + return await StoreRepository.GetSettingAsync(storeId, ShopifyStoreSettings.SettingsName); + } + } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Services/ShopifyHostedService.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Services/ShopifyHostedService.cs new file mode 100644 index 0000000..894c2ef --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Services/ShopifyHostedService.cs @@ -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(); + 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 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; + } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ShopifyApiException.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ShopifyApiException.cs new file mode 100644 index 0000000..9f682de --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ShopifyApiException.cs @@ -0,0 +1,11 @@ +using System; + +namespace BTCPayServer.Plugins.ShopifyPlugin +{ + public class ShopifyApiException : Exception + { + public ShopifyApiException(string message) : base(message) + { + } + } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ShopifyStoreSettings.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ShopifyStoreSettings.cs new file mode 100644 index 0000000..de01645 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ShopifyStoreSettings.cs @@ -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"; + } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/BaseShopifyPublicViewModel.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/BaseShopifyPublicViewModel.cs new file mode 100644 index 0000000..4397ff4 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/BaseShopifyPublicViewModel.cs @@ -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; } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/OrdersCreateWebhook.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/OrdersCreateWebhook.cs new file mode 100644 index 0000000..e82907d --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/OrdersCreateWebhook.cs @@ -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 + { + } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Requests/CancelOrderRequest.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Requests/CancelOrderRequest.cs new file mode 100644 index 0000000..17f6ee1 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Requests/CancelOrderRequest.cs @@ -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; } +} + diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Requests/CaptureOrderRequest.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Requests/CaptureOrderRequest.cs new file mode 100644 index 0000000..d014894 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Requests/CaptureOrderRequest.cs @@ -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; } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Requests/ShopifyOrderViewModel.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Requests/ShopifyOrderViewModel.cs new file mode 100644 index 0000000..041986f --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Requests/ShopifyOrderViewModel.cs @@ -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; } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Requests/TransactionsCreateReq.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Requests/TransactionsCreateReq.cs new file mode 100644 index 0000000..66fb67f --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Requests/TransactionsCreateReq.cs @@ -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; } + } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/AccessTokenResponse.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/AccessTokenResponse.cs new file mode 100644 index 0000000..762c219 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/AccessTokenResponse.cs @@ -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; } +} + diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/CountResponse.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/CountResponse.cs new file mode 100644 index 0000000..18d201c --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/CountResponse.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models; + +public class CountResponse +{ + [JsonProperty("count")] + public long Count { get; set; } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/CreateWebhookResponse.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/CreateWebhookResponse.cs new file mode 100644 index 0000000..6bc5019 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/CreateWebhookResponse.cs @@ -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 Fields { get; set; } + + [JsonProperty("metafield_namespaces")] public List MetafieldNamespaces { get; set; } + + [JsonProperty("api_version")] public string ApiVersion { get; set; } + + [JsonProperty("private_metafield_namespaces")] + public List PrivateMetafieldNamespaces { get; set; } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/ShopifyOrder.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/ShopifyOrder.cs new file mode 100644 index 0000000..3663bc7 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/ShopifyOrder.cs @@ -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; } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/ShopifyOrderVm.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/ShopifyOrderVm.cs new file mode 100644 index 0000000..862be09 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/ShopifyOrderVm.cs @@ -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 LineItems { get; set; } + public List 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 TaxLines { get; set; } + public List Duties { get; set; } + public List 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 { } \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/ShopifyTransaction.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/ShopifyTransaction.cs new file mode 100644 index 0000000..5ab7fb9 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/ShopifyTransaction.cs @@ -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; } + + /// + /// A standardized error code, e.g. 'incorrect_number', independent of the payment provider. Value can be null. A full list of known values can be found at https://help.shopify.com/api/reference/transaction. + /// + [JsonProperty("error_code")] + public string ErrorCode { get; set; } + + /// + /// The status of the transaction. Valid values are: pending, failure, success or error. + /// + [JsonProperty("status")] + public string Status { get; set; } + [JsonProperty("test")] + public bool? Test { get; set; } + [JsonProperty("currency")] + public string Currency { get; set; } + /// + /// This property is undocumented by Shopify. + /// + [JsonProperty("parent_id")] + public long? ParentId { get; set; } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/TransactionDataHolder.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/TransactionDataHolder.cs new file mode 100644 index 0000000..98f2d97 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/TransactionDataHolder.cs @@ -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; } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/TransactionsCreateResp.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/TransactionsCreateResp.cs new file mode 100644 index 0000000..5095f5b --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/TransactionsCreateResp.cs @@ -0,0 +1,6 @@ +namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models; + +public class TransactionsCreateResp +{ + public TransactionDataHolder transaction { get; set; } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/TransactionsListResp.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/TransactionsListResp.cs new file mode 100644 index 0000000..5e221f0 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/Responses/TransactionsListResp.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace BTCPayServer.Plugins.ShopifyPlugin.ViewModels.Models; + +public class TransactionsListResp +{ + public List transactions { get; set; } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/ShopifySettingsViewModel.cs b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/ShopifySettingsViewModel.cs new file mode 100644 index 0000000..21c8cb9 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/ViewModels/Models/ShopifySettingsViewModel.cs @@ -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; } + } +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Views/Shared/ShopifyPluginHeaderNav.cshtml b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Views/Shared/ShopifyPluginHeaderNav.cshtml new file mode 100644 index 0000000..40c24ad --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Views/Shared/ShopifyPluginHeaderNav.cshtml @@ -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)) +{ + +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Views/UIShopify/Settings.cshtml b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Views/UIShopify/Settings.cshtml new file mode 100644 index 0000000..d10f138 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Views/UIShopify/Settings.cshtml @@ -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"; +} diff --git a/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Views/_ViewImports.cshtml b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Views/_ViewImports.cshtml new file mode 100644 index 0000000..2ce6775 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.ShopifyPlugin/Views/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, BTCPayServer +@addTagHelper *, BTCPayServer.Abstractions diff --git a/btcpayserver-shopify-plugin.sln b/btcpayserver-shopify-plugin.sln new file mode 100644 index 0000000..72fd980 --- /dev/null +++ b/btcpayserver-shopify-plugin.sln @@ -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 diff --git a/submodules/btcpayserver b/submodules/btcpayserver new file mode 160000 index 0000000..1c8ded9 --- /dev/null +++ b/submodules/btcpayserver @@ -0,0 +1 @@ +Subproject commit 1c8ded93629e5764004faea269c1ebce0a4a1dd1