Compare commits

...

18 Commits

Author SHA1 Message Date
nicolas.dorier
fd2828b295
Refactor the UpdateMetadatafield 2025-03-21 23:52:15 +09:00
Chukwuleta Tobechi
60f7439b94 Revert btcpayserver submodule to commit 1c8ded 2025-03-21 14:26:58 +01:00
Chukwuleta Tobechi
f69f9f340a resolve 2025-03-21 14:07:46 +01:00
Chukwuleta Tobechi
60dd6e2084 resolve conflict 2025-03-21 14:00:58 +01:00
Chukwuleta Tobechi
a02087c19c reorder params 2025-03-21 13:59:57 +01:00
Chukwuleta Tobechi
cdae3f7dd2 default redirect value 2025-03-21 13:59:57 +01:00
Chukwuleta Tobechi
09d0dd72a9 backward compatibility 2025-03-21 13:59:57 +01:00
Chukwuleta Tobechi
3e875d3134 refactor 2025-03-21 13:59:12 +01:00
Chukwuleta Tobechi
564da53c70 resolve feedback 2025-03-21 13:55:36 +01:00
Chukwuleta Tobechi
3233d8481a Include checkout url to shopify order metadata 2025-03-21 13:55:36 +01:00
Chukwuleta Tobechi
752e41f146 Auto creates invoice if the payment option is BTCPay Server 2025-03-21 13:55:36 +01:00
Chukwuleta Tobechi
29965e4050 reorder params 2025-03-21 12:44:02 +01:00
Chukwuleta Tobechi
8d1bf4b910 default redirect value 2025-03-21 12:40:42 +01:00
Chukwuleta Tobechi
de5b33af5e backward compatibility 2025-03-21 12:35:45 +01:00
Chukwuleta Tobechi
54f9aa05be refactor 2025-03-21 10:02:52 +01:00
Chukwuleta Tobechi
1ea76df627 resolve feedback 2025-03-21 09:01:50 +01:00
Chukwuleta Tobechi
8029215ba3 Include checkout url to shopify order metadata 2025-03-20 13:42:35 +01:00
Chukwuleta Tobechi
ffe4020fa4 Auto creates invoice if the payment option is BTCPay Server 2025-03-18 19:58:36 +01:00
8 changed files with 145 additions and 80 deletions

View File

@ -5,19 +5,13 @@ using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
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 System.Text.RegularExpressions;
using System.Security;
using Microsoft.AspNetCore.WebUtilities;
using JArray = Newtonsoft.Json.Linq.JArray;
@ -235,7 +229,7 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
}
const string OrderData =
"""
"""
fragment order on Order {
id
name
@ -254,8 +248,9 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
transactions @include(if: $includeTxs) {
...orderTransaction
}
...orderPaymentProcess
}
""" + "\n" + TransactionData;
""" + "\n" + TransactionData + "\n" + PaymentProcessData;
private const string TransactionData =
"""
@ -278,7 +273,14 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
}
}
""";
public async Task<ShopifyOrder> GetOrder(long orderId, bool withTransactions = false)
private const string PaymentProcessData =
"""
fragment orderPaymentProcess on Order {
paymentGatewayNames
}
""";
public async Task<ShopifyOrder> GetOrder(long orderId, bool withTransactions = false)
{
// https://shopify.dev/docs/api/admin-graphql/2024-10/queries/order
var req = """
@ -406,8 +408,30 @@ namespace BTCPayServer.Plugins.ShopifyPlugin.Clients
""";
JObject respObj = await SendGraphQL(req, new JObject() { ["id"] = ShopifyId.DraftOrder(orderId).ToString() });
return ShopifyId.Parse(respObj["data"]["draftOrderDuplicate"]["draftOrder"]["id"].Value<string>());
}
}
}
// https://shopify.dev/docs/api/admin-graphql/latest/mutations/orderUpdate
public async Task<ShopifyId> UpdateOrderMetafields(UpdateMetafields update)
{
var req = """
mutation updateOrderMetafields($input: OrderInput!) {
orderUpdate(input: $input) {
order {
id
}
userErrors {
message
field
}
}
}
""";
JObject respObj = await SendGraphQL(req, new JObject { ["input"] = JObject.FromObject(update, JsonSerializer) });
var d = Unwrap(respObj, "orderUpdate");
return ShopifyId.Parse(d["order"]["id"].Value<string>());
}
}
public record ShopifyApiClientCredentials

View File

@ -55,6 +55,7 @@ public class ShopifyOrder
public DateTimeOffset? CancelledAt { get; set; }
public ShopifyMoneyBag TotalOutstandingSet { get; set; }
public OrderTransaction[] Transactions { get; set; }
public string[] PaymentGatewayNames { get; set; }
}
public class ShopifyMoneyBag
{

View File

@ -0,0 +1,14 @@
namespace BTCPayServer.Plugins.ShopifyPlugin.Clients;
public class UpdateMetafields
{
public class Metafield
{
public string Namespace { get; set; }
public string Key { get; set; }
public string Type { get; set; }
public string Value { get; set; }
}
public ShopifyId Id { get; set; }
public Metafield[] Metafields { get; set; }
}

View File

@ -11,7 +11,6 @@ using BTCPayServer.Client;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Invoices;
using System.Collections.Generic;
using BTCPayServer.Plugins.ShopifyPlugin.Services;
using BTCPayServer.Client.Models;
using Newtonsoft.Json.Linq;
@ -28,6 +27,7 @@ using BTCPayServer.Filters;
using BTCPayServer.Plugins.ShopifyPlugin.Clients;
using BTCPayServer.Plugins.ShopifyPlugin.ViewModels;
using Microsoft.Extensions.Configuration;
using Microsoft.AspNetCore.Cors;
namespace BTCPayServer.Plugins.ShopifyPlugin;
@ -35,7 +35,7 @@ namespace BTCPayServer.Plugins.ShopifyPlugin;
[AutoValidateAntiforgeryToken]
public class UIShopifyV2Controller : Controller
{
private readonly StoreRepository _storeRepo;
private readonly StoreRepository _storeRepo;
private readonly InvoiceRepository _invoiceRepository;
private readonly UIInvoiceController _invoiceController;
@ -236,72 +236,93 @@ public class UIShopifyV2Controller : Controller
}
}
static AsyncDuplicateLock OrderLocks = new AsyncDuplicateLock();
[AllowAnonymous]
static AsyncDuplicateLock OrderLocks = new AsyncDuplicateLock();
[AllowAnonymous]
[EnableCors(CorsPolicies.All)]
[HttpGet("~/stores/{storeId}/plugins/shopify-v2/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, true);
var store = await _storeRepo.FindStore(storeId);
if (order is null || store is null)
return BadRequest("Invalid checkout token");
public async Task<IActionResult> Checkout(string storeId, string? checkout_token, CancellationToken cancellationToken, bool redirect = true)
{
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, true);
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()
var containsKeyword = order.PaymentGatewayNames.Any(pgName => ShopifyHostedService.IsBTCPayServerGateway(pgName));
if (!containsKeyword)
return BadRequest("Order wasn't fulfiled with BTCPay Server payment option");
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 redirect ? RedirectToInvoiceCheckout(currentInvoice.Id) : Ok();
var baseTx = order.Transactions.FirstOrDefault(t => t is { Kind: "SALE", ManuallyCapturable: true });
if (baseTx is null)
return BadRequest("The shopify order is not capturable");
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName);
var amount = order.TotalOutstandingSet.PresentmentMoney;
var invoice = await _invoiceController.CreateInvoiceCoreRaw(
new CreateInvoiceRequest()
{
Amount = amount.Amount,
Currency = amount.CurrencyCode,
Metadata = new JObject
{
["orderId"] = order.Name,
["orderUrl"] = GetOrderUrl(settings?.Setup?.ShopUrl, orderId),
["shopifyOrderId"] = orderId,
["shopifyOrderName"] = order.Name,
["gateway"] = baseTx.Gateway
},
AdditionalSearchTerms =
[
order.Name,
orderId.ToString(CultureInfo.InvariantCulture),
searchTerm
],
Checkout = new()
{
RedirectURL = order.StatusPageUrl
}
}, store,
Request.GetAbsoluteRoot(), [searchTerm], cancellationToken);
if (invoice == null) return BadRequest("An error occured while creating invoice");
await client.UpdateOrderMetafields(new()
{
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 baseTx = order.Transactions.FirstOrDefault(t => t is { Kind: "SALE", ManuallyCapturable: true });
if (baseTx is null)
return BadRequest("The shopify order is not capturable");
var settings = await _storeRepo.GetSettingAsync<ShopifyStoreSettings>(storeId, ShopifyStoreSettings.SettingsName);
var amount = order.TotalOutstandingSet.PresentmentMoney;
var invoice = await _invoiceController.CreateInvoiceCoreRaw(
new CreateInvoiceRequest()
Id = ShopifyId.Order(orderId),
Metafields = [
new()
{
Amount = amount.Amount,
Currency = amount.CurrencyCode,
Metadata = new JObject
{
["orderId"] = order.Name,
["orderUrl"] = GetOrderUrl(settings?.Setup?.ShopUrl, orderId),
["shopifyOrderId"] = orderId,
["shopifyOrderName"] = order.Name,
["gateway"] = baseTx.Gateway
},
AdditionalSearchTerms =
[
order.Name,
orderId.ToString(CultureInfo.InvariantCulture),
searchTerm
],
Checkout = new()
{
RedirectURL = order.StatusPageUrl
}
}, store,
Request.GetAbsoluteRoot(), [searchTerm], cancellationToken);
return RedirectToInvoiceCheckout(invoice.Id);
}
Namespace = "custom",
Key = "btcpay_checkout_url",
Type = "single_line_text_field",
Value = Url.Action(nameof(Checkout), "UIShopifyV2", new { storeId, checkout_token }, Request.Scheme)
}
]
});
return redirect ? RedirectToInvoiceCheckout(invoice.Id) : Ok();
}
private string? GetOrderUrl(string? shopUrl, long shopifyOrderId)
private string? GetOrderUrl(string? shopUrl, long shopifyOrderId)
{
var shopName = GetShopName(shopUrl);
if (shopName is null)

View File

@ -1,10 +1,7 @@
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;

View File

@ -9,7 +9,6 @@ using BTCPayServer.Services.Rates;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -72,6 +71,13 @@ public class ShopifyHostedService : EventHostedServiceBase
}
}
private static string[] _keywords = new[] { "bitcoin", "btc", "btcpayserver", "btcpay server" };
public static bool IsBTCPayServerGateway(string gateway)
{
return _keywords.Any(keyword => gateway.Contains(keyword, StringComparison.OrdinalIgnoreCase));
}
async Task<InvoiceLogs> Process(long shopifyOrderId, InvoiceEntity invoice)
{
var logs = new InvoiceLogs();
@ -80,7 +86,7 @@ public class ShopifyHostedService : EventHostedServiceBase
return logs;
if (await client.GetOrder(shopifyOrderId, true) is not { } order)
return logs;
var saleTx = order.Transactions.FirstOrDefault(h => h is { Kind: "SALE", Status: "PENDING" });
if (saleTx is null)
return logs;

View File

@ -147,6 +147,7 @@
<ul>
<li><a href="https://docs.btcpayserver.org/ShopifyV2/#customize-the-thank-you-page" rel="noreferrer noopener" target="_blank">Customize the checkout's "Thank You" page</a> on your store's <a href="https://admin.shopify.com/store/@(Model.ShopName)/settings/checkout" rel="noreferrer noopener" target="_blank">checkout settings page</a>.</li>
<li><a href="https://docs.btcpayserver.org/ShopifyV2/#set-up-a-custom-payment-method-in-shopify" rel="noreferrer noopener" target="_blank">Add BTCPay Server as a manual payment method</a> on your store's <a href="https://admin.shopify.com/store/@(Model.ShopName)/settings/payments" rel="noreferrer noopener" target="_blank">payment settings page</a>.</li>
<li>Grant network access to your application. Go to your app on your partner's dashboard > API Access > scroll down to 'Allow network access in checkout and account UI extensions' and grant network access.</li>
</ul>
}
else

View File

@ -16,8 +16,9 @@
{
<p class="lead text-secondary mt-3">Your BTCPay Server-Shopify installation was successful!</p>
<p class="mt-3">
<span>To start accepting payments, make sure to complete the two following steps:</span>
<span>To start accepting payments, make sure to complete the three following steps:</span>
<ul class="list-unstyled">
<li>Grant network access to your application. Go to your app on your partner's dashboard > API Access > scroll down to 'Allow network access in checkout and account UI extensions' and grant network access.</li>
<li><a href="https://docs.btcpayserver.org/ShopifyV2/#customize-the-thank-you-page" rel="noreferrer noopener" target="_blank">Customize the checkout's "Thank You" page</a> on <a href="https://admin.shopify.com/store/@(Model.ShopName)/settings/checkout" rel="noreferrer noopener" target="_blank">this page</a>.</li>
<li><a href="https://docs.btcpayserver.org/ShopifyV2/#set-up-a-custom-payment-method-in-shopify" rel="noreferrer noopener" target="_blank">Add BTCPay Server as a Manual payment method</a> on <a href="https://admin.shopify.com/store/@(Model.ShopName)/settings/payments" rel="noreferrer noopener" target="_blank">this page</a>.</li>
</ul>