Compare commits
18 Commits
master
...
fx/payment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd2828b295 | ||
|
|
60f7439b94 | ||
|
|
f69f9f340a | ||
|
|
60dd6e2084 | ||
|
|
a02087c19c | ||
|
|
cdae3f7dd2 | ||
|
|
09d0dd72a9 | ||
|
|
3e875d3134 | ||
|
|
564da53c70 | ||
|
|
3233d8481a | ||
|
|
752e41f146 | ||
|
|
29965e4050 | ||
|
|
8d1bf4b910 | ||
|
|
de5b33af5e | ||
|
|
54f9aa05be | ||
|
|
1ea76df627 | ||
|
|
8029215ba3 | ||
|
|
ffe4020fa4 |
@ -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
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user