Merge pull request #1760 from btcpayserver/api/invoice

GreenField: Invoice API
This commit is contained in:
Nicolas Dorier
2020-08-27 19:50:14 +09:00
committed by GitHub
63 changed files with 1791 additions and 318 deletions

View File

@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Client
{
public partial class BTCPayServerClient
{
public virtual async Task<IEnumerable<InvoiceData>> GetInvoices(string storeId, bool includeArchived = false,
CancellationToken token = default)
{
var response =
await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/stores/{storeId}/invoices",
new Dictionary<string, object>() {{nameof(includeArchived), includeArchived}}), token);
return await HandleResponse<IEnumerable<InvoiceData>>(response);
}
public virtual async Task<InvoiceData> GetInvoice(string storeId, string invoiceId,
CancellationToken token = default)
{
var response = await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/stores/{storeId}/invoices/{invoiceId}"), token);
return await HandleResponse<InvoiceData>(response);
}
public virtual async Task ArchiveInvoice(string storeId, string invoiceId,
CancellationToken token = default)
{
var response = await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/stores/{storeId}/invoices/{invoiceId}",
method: HttpMethod.Delete), token);
await HandleResponse(response);
}
public virtual async Task<InvoiceData> CreateInvoice(string storeId,
CreateInvoiceRequest request, CancellationToken token = default)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
var response = await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/stores/{storeId}/invoices", bodyPayload: request,
method: HttpMethod.Post), token);
return await HandleResponse<InvoiceData>(response);
}
public virtual async Task<InvoiceData> MarkInvoiceStatus(string storeId, string invoiceId,
MarkInvoiceStatusRequest request, CancellationToken token = default)
{
if (request == null)
throw new ArgumentNullException(nameof(request));
if (request.Status!= InvoiceStatus.Complete && request.Status!= InvoiceStatus.Invalid)
throw new ArgumentOutOfRangeException(nameof(request.Status), "Status can only be Invalid or Complete");
var response = await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/stores/{storeId}/invoices/{invoiceId}/status", bodyPayload: request,
method: HttpMethod.Post), token);
return await HandleResponse<InvoiceData>(response);
}
public virtual async Task<InvoiceData> UnarchiveInvoice(string storeId, string invoiceId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/stores/{storeId}/invoices/{invoiceId}/unarchive",
method: HttpMethod.Post), token);
return await HandleResponse<InvoiceData>(response);
}
}
}

View File

@@ -10,10 +10,13 @@ namespace BTCPayServer.Client
public partial class BTCPayServerClient
{
public virtual async Task<IEnumerable<PaymentRequestData>> GetPaymentRequests(string storeId,
bool includeArchived = false,
CancellationToken token = default)
{
var response =
await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payment-requests"), token);
await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/stores/{storeId}/payment-requests",
new Dictionary<string, object>() {{nameof(includeArchived), includeArchived}}), token);
return await HandleResponse<IEnumerable<PaymentRequestData>>(response);
}

View File

@@ -4,13 +4,38 @@ using Newtonsoft.Json;
namespace BTCPayServer.Client.JsonConverters
{
public class TimeSpanJsonConverter : JsonConverter
public abstract class TimeSpanJsonConverter : JsonConverter
{
public class Seconds : TimeSpanJsonConverter
{
protected override long ToLong(TimeSpan value)
{
return (long)value.TotalSeconds;
}
protected override TimeSpan ToTimespan(long value)
{
return TimeSpan.FromSeconds(value);
}
}
public class Minutes : TimeSpanJsonConverter
{
protected override long ToLong(TimeSpan value)
{
return (long)value.TotalMinutes;
}
protected override TimeSpan ToTimespan(long value)
{
return TimeSpan.FromMinutes(value);
}
}
public override bool CanConvert(Type objectType)
{
return objectType == typeof(TimeSpan) || objectType == typeof(TimeSpan?);
}
protected abstract TimeSpan ToTimespan(long value);
protected abstract long ToLong(TimeSpan value);
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
try
@@ -24,11 +49,11 @@ namespace BTCPayServer.Client.JsonConverters
}
if (reader.TokenType != JsonToken.Integer)
throw new JsonObjectException("Invalid timespan, expected integer", reader);
return TimeSpan.FromSeconds((long)reader.Value);
return ToTimespan((long)reader.Value);
}
catch
{
throw new JsonObjectException("Invalid locktime", reader);
throw new JsonObjectException("Invalid timespan", reader);
}
}
@@ -36,7 +61,7 @@ namespace BTCPayServer.Client.JsonConverters
{
if (value is TimeSpan s)
{
writer.WriteValue((long)s.TotalSeconds);
writer.WriteValue(ToLong(s));
}
}
}

View File

@@ -0,0 +1,7 @@
namespace BTCPayServer.Client.Models
{
public class AddCustomerEmailRequest
{
public string Email { get; set; }
}
}

View File

@@ -0,0 +1,35 @@
using System;
using BTCPayServer.Client.JsonConverters;
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models
{
public class CreateInvoiceRequest
{
[JsonProperty(ItemConverterType = typeof(NumericStringJsonConverter))]
public decimal Amount { get; set; }
public string Currency { get; set; }
public JObject Metadata { get; set; }
public CheckoutOptions Checkout { get; set; } = new CheckoutOptions();
public class CheckoutOptions
{
[JsonConverter(typeof(StringEnumConverter))]
public SpeedPolicy? SpeedPolicy { get; set; }
public string[] PaymentMethods { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter.Minutes))]
[JsonProperty("expirationMinutes")]
public TimeSpan? Expiration { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter.Minutes))]
[JsonProperty("monitoringMinutes")]
public TimeSpan? Monitoring { get; set; }
public double? PaymentTolerance { get; set; }
}
}
}

View File

@@ -20,7 +20,7 @@ namespace BTCPayServer.Client.Models
[JsonConverter(typeof(LightMoneyJsonConverter))]
public LightMoney Amount { get; set; }
public string Description { get; set; }
[JsonConverter(typeof(JsonConverters.TimeSpanJsonConverter))]
[JsonConverter(typeof(JsonConverters.TimeSpanJsonConverter.Seconds))]
public TimeSpan Expiry { get; set; }
public bool PrivateRouteHints { get; set; }

View File

@@ -11,7 +11,7 @@ namespace BTCPayServer.Client.Models
[JsonProperty(ItemConverterType = typeof(NumericStringJsonConverter))]
public decimal Amount { get; set; }
public string Currency { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter))]
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
public TimeSpan? Period { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? ExpiresAt { get; set; }

View File

@@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Client.Models
{
public class InvoiceData : CreateInvoiceRequest
{
public string Id { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public InvoiceStatus Status { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public InvoiceExceptionStatus AdditionalStatus { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset MonitoringExpiration { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset ExpirationTime { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset CreatedTime { get; set; }
}
}

View File

@@ -0,0 +1,12 @@
namespace BTCPayServer.Client.Models
{
public enum InvoiceExceptionStatus
{
None,
PaidLate,
PaidPartial,
Marked,
Invalid,
PaidOver
}
}

View File

@@ -0,0 +1,12 @@
namespace BTCPayServer.Client.Models
{
public enum InvoiceStatus
{
New,
Paid,
Expired,
Invalid,
Complete,
Confirmed
}
}

View File

@@ -0,0 +1,11 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Client.Models
{
public class MarkInvoiceStatusRequest
{
[JsonConverter(typeof(StringEnumConverter))]
public InvoiceStatus Status { get; set; }
}
}

View File

@@ -16,7 +16,7 @@ namespace BTCPayServer.Client.Models
public string Currency { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Amount { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter))]
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
public TimeSpan? Period { get; set; }
public bool Archived { get; set; }
public string ViewLink { get; set; }

View File

@@ -16,11 +16,11 @@ namespace BTCPayServer.Client.Models
public string Website { get; set; }
[JsonConverter(typeof(TimeSpanJsonConverter))]
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public TimeSpan InvoiceExpiration { get; set; } = TimeSpan.FromMinutes(15);
[JsonConverter(typeof(TimeSpanJsonConverter))]
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public TimeSpan MonitoringExpiration { get; set; } = TimeSpan.FromMinutes(60);

View File

@@ -14,6 +14,7 @@ namespace BTCPayServer.Client
public const string CanModifyStoreSettings = "btcpay.store.canmodifystoresettings";
public const string CanModifyStoreSettingsUnscoped = "btcpay.store.canmodifystoresettings:";
public const string CanViewStoreSettings = "btcpay.store.canviewstoresettings";
public const string CanViewInvoices = "btcpay.store.canviewinvoices";
public const string CanCreateInvoice = "btcpay.store.cancreateinvoice";
public const string CanViewPaymentRequests = "btcpay.store.canviewpaymentrequests";
public const string CanModifyPaymentRequests = "btcpay.store.canmodifypaymentrequests";
@@ -26,6 +27,7 @@ namespace BTCPayServer.Client
{
get
{
yield return CanViewInvoices;
yield return CanCreateInvoice;
yield return CanModifyServerSettings;
yield return CanModifyStoreSettings;
@@ -153,6 +155,8 @@ namespace BTCPayServer.Client
return true;
switch (subpolicy)
{
case Policies.CanViewInvoices when this.Policy == Policies.CanModifyStoreSettings:
case Policies.CanViewInvoices when this.Policy == Policies.CanViewStoreSettings:
case Policies.CanViewStoreSettings when this.Policy == Policies.CanModifyStoreSettings:
case Policies.CanCreateInvoice when this.Policy == Policies.CanModifyStoreSettings:
case Policies.CanViewProfile when this.Policy == Policies.CanModifyProfile:

View File

@@ -877,7 +877,7 @@ normal:
InvoiceEntity invoiceEntity = new InvoiceEntity();
invoiceEntity.Networks = networkProvider;
invoiceEntity.Payments = new System.Collections.Generic.List<PaymentEntity>();
invoiceEntity.ProductInformation = new ProductInformation() { Price = 100 };
invoiceEntity.Price = 100;
PaymentMethodDictionary paymentMethods = new PaymentMethodDictionary();
paymentMethods.Add(new PaymentMethod() { Network = networkBTC, CryptoCode = "BTC", Rate = 10513.44m, }
.SetPaymentMethodDetails(

View File

@@ -89,7 +89,7 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("AddApiKey")).Click();
s.Driver.FindElement(By.CssSelector("button[value='btcpay.store.canmodifystoresettings:change-store-mode']")).Click();
//there should be a store already by default in the dropdown
var dropdown = s.Driver.FindElement(By.Name("PermissionValues[2].SpecificStores[0]"));
var dropdown = s.Driver.FindElement(By.Name("PermissionValues[3].SpecificStores[0]"));
var option = dropdown.FindElement(By.TagName("option"));
var storeId = option.GetAttribute("value");
option.Click();

View File

@@ -17,9 +17,6 @@
<PropertyGroup Condition="'$(CI_TESTS)' == 'true'">
<DefineConstants>$(DefineConstants);SHORT_TIMEOUT</DefineConstants>
</PropertyGroup>
<PropertyGroup>
<DefineConstants>$(DefineConstants);ALTCOINS</DefineConstants>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" />

View File

@@ -10,6 +10,7 @@ using BTCPayServer.Controllers;
using BTCPayServer.Events;
using BTCPayServer.JsonConverters;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Tests.Logging;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -17,6 +18,7 @@ using NBitcoin;
using NBitpayClient;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUglify.Helpers;
using Xunit;
using Xunit.Abstractions;
using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest;
@@ -732,6 +734,147 @@ namespace BTCPayServer.Tests
}
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task InvoiceLegacyTests()
{
using (var tester = ServerTester.Create())
{
await tester.StartAsync();
var user = tester.NewAccount();
await user.GrantAccessAsync();
user.RegisterDerivationScheme("BTC");
var client = await user.CreateClient(Policies.Unrestricted);
var oldBitpay = user.BitPay;
Logs.Tester.LogInformation("Let's create an invoice with bitpay API");
var oldInvoice = await oldBitpay.CreateInvoiceAsync(new Invoice()
{
Currency = "BTC",
Price = 1000.19392922m,
BuyerAddress1 = "blah",
Buyer = new Buyer()
{
Address2 = "blah2"
},
ItemCode = "code",
ItemDesc = "desc",
OrderId = "orderId",
PosData = "posData"
});
async Task<Client.Models.InvoiceData> AssertInvoiceMetadata()
{
Logs.Tester.LogInformation("Let's check if we can get invoice in the new format with the metadata");
var newInvoice = await client.GetInvoice(user.StoreId, oldInvoice.Id);
Assert.Equal("posData", newInvoice.Metadata["posData"].Value<string>());
Assert.Equal("code", newInvoice.Metadata["itemCode"].Value<string>());
Assert.Equal("desc", newInvoice.Metadata["itemDesc"].Value<string>());
Assert.Equal("orderId", newInvoice.Metadata["orderId"].Value<string>());
Assert.False(newInvoice.Metadata["physical"].Value<bool>());
Assert.Null(newInvoice.Metadata["buyerCountry"]);
Assert.Equal(1000.19392922m, newInvoice.Amount);
Assert.Equal("BTC", newInvoice.Currency);
return newInvoice;
}
await AssertInvoiceMetadata();
Logs.Tester.LogInformation("Let's hack the Bitpay created invoice to be just like before this update. (Invoice V1)");
var invoiceV1 = "{\r\n \"version\": 1,\r\n \"id\": \"" + oldInvoice.Id + "\",\r\n \"storeId\": \"" + user.StoreId + "\",\r\n \"orderId\": \"orderId\",\r\n \"speedPolicy\": 1,\r\n \"rate\": 1.0,\r\n \"invoiceTime\": 1598329634,\r\n \"expirationTime\": 1598330534,\r\n \"depositAddress\": \"mm83rVs8ZnZok1SkRBmXiwQSiPFgTgCKpD\",\r\n \"productInformation\": {\r\n \"itemDesc\": \"desc\",\r\n \"itemCode\": \"code\",\r\n \"physical\": false,\r\n \"price\": 1000.19392922,\r\n \"currency\": \"BTC\"\r\n },\r\n \"buyerInformation\": {\r\n \"buyerName\": null,\r\n \"buyerEmail\": null,\r\n \"buyerCountry\": null,\r\n \"buyerZip\": null,\r\n \"buyerState\": null,\r\n \"buyerCity\": null,\r\n \"buyerAddress2\": \"blah2\",\r\n \"buyerAddress1\": \"blah\",\r\n \"buyerPhone\": null\r\n },\r\n \"posData\": \"posData\",\r\n \"internalTags\": [],\r\n \"derivationStrategy\": null,\r\n \"derivationStrategies\": \"{\\\"BTC\\\":{\\\"signingKey\\\":\\\"tpubDD1AW2ruUxSsDa55NQYtNt7DQw9bqXx4K7r2aScySmjxHtsCZoxFTN3qCMcKLxgsRDMGSwk9qj1fBfi8jqSLenwyYkhDrmgaxQuvuKrTHEf\\\",\\\"source\\\":\\\"NBXplorer\\\",\\\"accountDerivation\\\":\\\"tpubDD1AW2ruUxSsDa55NQYtNt7DQw9bqXx4K7r2aScySmjxHtsCZoxFTN3qCMcKLxgsRDMGSwk9qj1fBfi8jqSLenwyYkhDrmgaxQuvuKrTHEf-[legacy]\\\",\\\"accountOriginal\\\":null,\\\"accountKeySettings\\\":[{\\\"rootFingerprint\\\":\\\"54d5044d\\\",\\\"accountKeyPath\\\":\\\"44'/1'/0'\\\",\\\"accountKey\\\":\\\"tpubDD1AW2ruUxSsDa55NQYtNt7DQw9bqXx4K7r2aScySmjxHtsCZoxFTN3qCMcKLxgsRDMGSwk9qj1fBfi8jqSLenwyYkhDrmgaxQuvuKrTHEf\\\"}],\\\"label\\\":null}}\",\r\n \"status\": \"new\",\r\n \"exceptionStatus\": \"\",\r\n \"payments\": [],\r\n \"refundable\": false,\r\n \"refundMail\": null,\r\n \"redirectURL\": null,\r\n \"redirectAutomatically\": false,\r\n \"txFee\": 0,\r\n \"fullNotifications\": false,\r\n \"notificationEmail\": null,\r\n \"notificationURL\": null,\r\n \"serverUrl\": \"http://127.0.0.1:8001\",\r\n \"cryptoData\": {\r\n \"BTC\": {\r\n \"rate\": 1.0,\r\n \"paymentMethod\": {\r\n \"networkFeeMode\": 0,\r\n \"networkFeeRate\": 100.0,\r\n \"payjoinEnabled\": false\r\n },\r\n \"feeRate\": 100.0,\r\n \"txFee\": 0,\r\n \"depositAddress\": \"mm83rVs8ZnZok1SkRBmXiwQSiPFgTgCKpD\"\r\n }\r\n },\r\n \"monitoringExpiration\": 1598416934,\r\n \"historicalAddresses\": null,\r\n \"availableAddressHashes\": null,\r\n \"extendedNotifications\": false,\r\n \"events\": null,\r\n \"paymentTolerance\": 0.0,\r\n \"archived\": false\r\n}";
var db = tester.PayTester.GetService<Data.ApplicationDbContextFactory>();
using var ctx = db.CreateContext();
var dbInvoice = await ctx.Invoices.FindAsync(oldInvoice.Id);
dbInvoice.Blob = ZipUtils.Zip(invoiceV1);
await ctx.SaveChangesAsync();
var newInvoice = await AssertInvoiceMetadata();
Logs.Tester.LogInformation("Now, let's create an invoice with the new API but with the same metadata as Bitpay");
newInvoice.Metadata.Add("lol", "lol");
newInvoice = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest()
{
Metadata = newInvoice.Metadata,
Amount = 1000.19392922m,
Currency = "BTC"
});
oldInvoice = await oldBitpay.GetInvoiceAsync(newInvoice.Id);
await AssertInvoiceMetadata();
Assert.Equal("lol", newInvoice.Metadata["lol"].Value<string>());
}
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task InvoiceTests()
{
using (var tester = ServerTester.Create())
{
await tester.StartAsync();
var user = tester.NewAccount();
await user.GrantAccessAsync();
await user.MakeAdmin();
var client = await user.CreateClient(Policies.Unrestricted);
var viewOnly = await user.CreateClient(Policies.CanViewInvoices);
//create
//validation errors
await AssertValidationError(new[] { nameof(CreateInvoiceRequest.Currency), nameof(CreateInvoiceRequest.Amount), $"{nameof(CreateInvoiceRequest.Checkout)}.{nameof(CreateInvoiceRequest.Checkout.PaymentTolerance)}", $"{nameof(CreateInvoiceRequest.Checkout)}.{nameof(CreateInvoiceRequest.Checkout.PaymentMethods)}[0]" }, async () =>
{
await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest() { Amount = -1, Checkout = new CreateInvoiceRequest.CheckoutOptions() { PaymentTolerance = -2, PaymentMethods = new[] { "jasaas_sdsad" } } });
});
await AssertHttpError(403, async () =>
{
await viewOnly.CreateInvoice(user.StoreId,
new CreateInvoiceRequest() { Currency = "helloinvalid", Amount = 1 });
});
await user.RegisterDerivationSchemeAsync("BTC");
var newInvoice = await client.CreateInvoice(user.StoreId,
new CreateInvoiceRequest() { Currency = "USD", Amount = 1, Metadata = JObject.Parse("{\"itemCode\": \"testitem\"}") });
//list
var invoices = await viewOnly.GetInvoices(user.StoreId);
Assert.NotNull(invoices);
Assert.Single(invoices);
Assert.Equal(newInvoice.Id, invoices.First().Id);
//get payment request
var invoice = await viewOnly.GetInvoice(user.StoreId, newInvoice.Id);
Assert.Equal(newInvoice.Metadata, invoice.Metadata);
//update
invoice = await viewOnly.GetInvoice(user.StoreId, newInvoice.Id);
await AssertValidationError(new[] { nameof(MarkInvoiceStatusRequest.Status) }, async () =>
{
await client.MarkInvoiceStatus(user.StoreId, invoice.Id, new MarkInvoiceStatusRequest()
{
Status = InvoiceStatus.Complete
});
});
//archive
await AssertHttpError(403, async () =>
{
await viewOnly.ArchiveInvoice(user.StoreId, invoice.Id);
});
await client.ArchiveInvoice(user.StoreId, invoice.Id);
Assert.DoesNotContain(invoice.Id,
(await client.GetInvoices(user.StoreId)).Select(data => data.Id));
//unarchive
await client.UnarchiveInvoice(user.StoreId, invoice.Id);
Assert.NotNull(await client.GetInvoice(user.StoreId, invoice.Id));
}
}
[Fact(Timeout = TestTimeout)]
[Trait("Fast", "Fast")]
public void NumericJsonConverterTests()
@@ -762,10 +905,11 @@ namespace BTCPayServer.Tests
Assert.Throws<JsonSerializationException>(() =>
{
jsonConverter.ReadJson(Get("null"), typeof(decimal), null, null);
});Assert.Throws<JsonSerializationException>(() =>
{
jsonConverter.ReadJson(Get("null"), typeof(double), null, null);
});
Assert.Throws<JsonSerializationException>(() =>
{
jsonConverter.ReadJson(Get("null"), typeof(double), null, null);
});
Assert.Equal(1.2m, jsonConverter.ReadJson(Get(stringJson), typeof(decimal), null, null));
Assert.Equal(1.2m, jsonConverter.ReadJson(Get(stringJson), typeof(decimal?), null, null));
Assert.Equal(1.2, jsonConverter.ReadJson(Get(stringJson), typeof(double), null, null));

View File

@@ -3,6 +3,7 @@ using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Events;

View File

@@ -1,6 +1,7 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.PaymentRequest;

View File

@@ -151,6 +151,36 @@ namespace BTCPayServer.Tests
}
}
[Fact]
[Trait("Fast", "Fast")]
public void CanParsePaymentMethodId()
{
var id = PaymentMethodId.Parse("BTC");
var id1 = PaymentMethodId.Parse("BTC-OnChain");
var id2 = PaymentMethodId.Parse("BTC-BTCLike");
Assert.Equal(id, id1);
Assert.Equal(id, id2);
Assert.Equal("BTC", id.ToString());
Assert.Equal("BTC", id.ToString());
id = PaymentMethodId.Parse("LTC");
Assert.Equal("LTC", id.ToString());
Assert.Equal("LTC", id.ToStringNormalized());
id = PaymentMethodId.Parse("LTC-offchain");
id1 = PaymentMethodId.Parse("LTC-OffChain");
id2 = PaymentMethodId.Parse("LTC-LightningLike");
Assert.Equal(id, id1);
Assert.Equal(id, id2);
Assert.Equal("LTC_LightningLike", id.ToString());
Assert.Equal("LTC-LightningNetwork", id.ToStringNormalized());
#if ALTCOINS
id = PaymentMethodId.Parse("XMR");
id1 = PaymentMethodId.Parse("XMR-MoneroLike");
Assert.Equal(id, id1);
Assert.Equal("XMR_MoneroLike", id.ToString());
Assert.Equal("XMR", id.ToStringNormalized());
#endif
}
[Fact]
[Trait("Fast", "Fast")]
public async Task CheckNoDeadLink()
@@ -325,7 +355,7 @@ namespace BTCPayServer.Tests
Assert.True(Torrc.TryParse(input, out torrc));
Assert.Equal(expected, torrc.ToString());
}
#if ALTCOINS
[Fact]
[Trait("Fast", "Fast")]
public void CanCalculateCryptoDue()
@@ -346,7 +376,7 @@ namespace BTCPayServer.Tests
Rate = 5000,
NextNetworkFee = Money.Coins(0.1m)
});
entity.ProductInformation = new ProductInformation() { Price = 5000 };
entity.Price = 5000;
var paymentMethod = entity.GetPaymentMethods().TryGet("BTC", PaymentTypes.BTCLike);
var accounting = paymentMethod.Calculate();
@@ -396,7 +426,7 @@ namespace BTCPayServer.Tests
entity = new InvoiceEntity();
entity.Networks = networkProvider;
entity.ProductInformation = new ProductInformation() { Price = 5000 };
entity.Price = 5000;
PaymentMethodDictionary paymentMethods = new PaymentMethodDictionary();
paymentMethods.Add(
new PaymentMethod() { CryptoCode = "BTC", Rate = 1000, NextNetworkFee = Money.Coins(0.1m) });
@@ -490,7 +520,7 @@ namespace BTCPayServer.Tests
Assert.Equal(accounting.Paid, accounting.TotalDue);
#pragma warning restore CS0618
}
#endif
[Fact]
[Trait("Integration", "Integration")]
public async Task CanUseTestWebsiteUI()
@@ -545,7 +575,7 @@ namespace BTCPayServer.Tests
Rate = 5000,
NextNetworkFee = Money.Coins(0.1m)
});
entity.ProductInformation = new ProductInformation() { Price = 5000 };
entity.Price = 5000;
entity.PaymentTolerance = 0;

View File

@@ -246,5 +246,5 @@
<_ContentIncludedByDefault Remove="Views\Components\NotificationsDropdown\Default.cshtml" />
</ItemGroup>
<ProjectExtensions><VisualStudio><UserProperties wwwroot_4swagger_4v1_4swagger_1template_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1pull-payments_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1serverinfo_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" /></VisualStudio></ProjectExtensions>
<ProjectExtensions><VisualStudio><UserProperties wwwroot_4swagger_4v1_4swagger_1template_1invoices_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1pull-payments_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1serverinfo_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" /></VisualStudio></ProjectExtensions>
</Project>

View File

@@ -175,7 +175,7 @@ namespace BTCPayServer.Controllers
var store = await _AppService.GetStore(app);
try
{
var invoice = await _InvoiceController.CreateInvoiceCore(new CreateInvoiceRequest()
var invoice = await _InvoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest()
{
ItemCode = choice?.Id,
ItemDesc = title,
@@ -317,7 +317,7 @@ namespace BTCPayServer.Controllers
try
{
var invoice = await _InvoiceController.CreateInvoiceCore(new CreateInvoiceRequest()
var invoice = await _InvoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest()
{
OrderId = AppService.GetCrowdfundOrderId(appId),
Currency = settings.TargetCurrency,

View File

@@ -0,0 +1,242 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Payments;
using BTCPayServer.Security;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore.Metadata.Conventions;
using NBitcoin;
using NBitpayClient;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using CreateInvoiceRequest = BTCPayServer.Client.Models.CreateInvoiceRequest;
using InvoiceData = BTCPayServer.Client.Models.InvoiceData;
namespace BTCPayServer.Controllers.GreenField
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenFieldInvoiceController : Controller
{
private readonly InvoiceController _invoiceController;
private readonly InvoiceRepository _invoiceRepository;
public GreenFieldInvoiceController(InvoiceController invoiceController, InvoiceRepository invoiceRepository)
{
_invoiceController = invoiceController;
_invoiceRepository = invoiceRepository;
}
[Authorize(Policy = Policies.CanViewInvoices,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/invoices")]
public async Task<IActionResult> GetInvoices(string storeId, bool includeArchived = false)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return NotFound();
}
var invoices =
await _invoiceRepository.GetInvoices(new InvoiceQuery()
{
StoreId = new[] { store.Id },
IncludeArchived = includeArchived
});
return Ok(invoices.Select(ToModel));
}
[Authorize(Policy = Policies.CanViewInvoices,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/invoices/{invoiceId}")]
public async Task<IActionResult> GetInvoice(string storeId, string invoiceId)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return NotFound();
}
var invoice = await _invoiceRepository.GetInvoice(invoiceId, true);
if (invoice.StoreId != store.Id)
{
return NotFound();
}
return Ok(ToModel(invoice));
}
[Authorize(Policy = Policies.CanModifyStoreSettings,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpDelete("~/api/v1/stores/{storeId}/invoices/{invoiceId}")]
public async Task<IActionResult> ArchiveInvoice(string storeId, string invoiceId)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return NotFound();
}
await _invoiceRepository.ToggleInvoiceArchival(invoiceId, true, storeId);
return Ok();
}
[Authorize(Policy = Policies.CanCreateInvoice,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/invoices")]
public async Task<IActionResult> CreateInvoice(string storeId, CreateInvoiceRequest request)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return NotFound();
}
if (request.Amount < 0.0m)
{
ModelState.AddModelError(nameof(request.Amount), "The amount should be 0 or more.");
}
if (string.IsNullOrEmpty(request.Currency))
{
ModelState.AddModelError(nameof(request.Currency), "Currency is required");
}
if (request.Checkout.PaymentMethods?.Any() is true)
{
for (int i = 0; i < request.Checkout.PaymentMethods.Length; i++)
{
if (!PaymentMethodId.TryParse(request.Checkout.PaymentMethods[i], out _))
{
request.AddModelError(invoiceRequest => invoiceRequest.Checkout.PaymentMethods[i],
"Invalid payment method", this);
}
}
}
if (request.Checkout.Expiration != null && request.Checkout.Expiration < TimeSpan.FromSeconds(30.0))
{
request.AddModelError(invoiceRequest => invoiceRequest.Checkout.Expiration,
"Expiration time must be at least 30 seconds", this);
}
if (request.Checkout.PaymentTolerance != null &&
(request.Checkout.PaymentTolerance < 0 || request.Checkout.PaymentTolerance > 100))
{
request.AddModelError(invoiceRequest => invoiceRequest.Checkout.PaymentTolerance,
"PaymentTolerance can only be between 0 and 100 percent", this);
}
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
try
{
var invoice = await _invoiceController.CreateInvoiceCoreRaw(request, store,
Request.GetAbsoluteUri(""));
return Ok(ToModel(invoice));
}
catch (BitpayHttpException e)
{
return this.CreateAPIError(null, e.Message);
}
}
[Authorize(Policy = Policies.CanModifyStoreSettings,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/invoices/{invoiceId}/status")]
public async Task<IActionResult> MarkInvoiceStatus(string storeId, string invoiceId,
MarkInvoiceStatusRequest request)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return NotFound();
}
var invoice = await _invoiceRepository.GetInvoice(invoiceId, true);
if (invoice.StoreId != store.Id)
{
return NotFound();
}
if (!await _invoiceRepository.MarkInvoiceStatus(invoice.Id, request.Status))
{
ModelState.AddModelError(nameof(request.Status),
"Status can only be marked to invalid or complete within certain conditions.");
}
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
return await GetInvoice(storeId, invoiceId);
}
[Authorize(Policy = Policies.CanModifyStoreSettings,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/invoices/{invoiceId}/unarchive")]
public async Task<IActionResult> UnarchiveInvoice(string storeId, string invoiceId)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return NotFound();
}
var invoice = await _invoiceRepository.GetInvoice(invoiceId, true);
if (invoice.StoreId != store.Id)
{
return NotFound();
}
if (!invoice.Archived)
{
return this.CreateAPIError("already-unarchived", "Invoice is already unarchived");
}
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
await _invoiceRepository.ToggleInvoiceArchival(invoiceId, false, storeId);
return await GetInvoice(storeId, invoiceId);
}
private InvoiceData ToModel(InvoiceEntity entity)
{
return new InvoiceData()
{
ExpirationTime = entity.ExpirationTime,
MonitoringExpiration = entity.MonitoringExpiration,
CreatedTime = entity.InvoiceTime,
Amount = entity.Price,
Id = entity.Id,
Status = entity.Status,
AdditionalStatus = entity.ExceptionStatus,
Currency = entity.Currency,
Metadata = entity.Metadata.ToJObject(),
Checkout = new CreateInvoiceRequest.CheckoutOptions()
{
Expiration = entity.ExpirationTime - entity.InvoiceTime,
Monitoring = entity.MonitoringExpiration - entity.ExpirationTime,
PaymentTolerance = entity.PaymentTolerance,
PaymentMethods =
entity.GetPaymentMethods().Select(method => method.GetId().ToStringNormalized()).ToArray(),
SpeedPolicy = entity.SpeedPolicy
}
};
}
}
}

View File

@@ -32,10 +32,10 @@ namespace BTCPayServer.Controllers.GreenField
[Authorize(Policy = Policies.CanViewPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payment-requests")]
public async Task<ActionResult<IEnumerable<PaymentRequestData>>> GetPaymentRequests(string storeId)
public async Task<ActionResult<IEnumerable<PaymentRequestData>>> GetPaymentRequests(string storeId, bool includeArchived = false)
{
var prs = await _paymentRequestRepository.FindPaymentRequests(
new PaymentRequestQuery() { StoreId = storeId, IncludeArchived = false });
new PaymentRequestQuery() { StoreId = storeId, IncludeArchived = includeArchived });
return Ok(prs.Items.Select(FromModel));
}

View File

@@ -124,8 +124,8 @@ namespace BTCPayServer.Controllers.GreenField
ShowRecommendedFee = storeBlob.ShowRecommendedFee,
RecommendedFeeBlockTarget = storeBlob.RecommendedFeeBlockTarget,
DefaultLang = storeBlob.DefaultLang,
MonitoringExpiration = TimeSpan.FromMinutes(storeBlob.MonitoringExpiration),
InvoiceExpiration = TimeSpan.FromMinutes(storeBlob.InvoiceExpiration),
MonitoringExpiration = storeBlob.MonitoringExpiration,
InvoiceExpiration = storeBlob.InvoiceExpiration,
LightningAmountInSatoshi = storeBlob.LightningAmountInSatoshi,
CustomLogo = storeBlob.CustomLogo,
CustomCSS = storeBlob.CustomCSS,
@@ -160,8 +160,8 @@ namespace BTCPayServer.Controllers.GreenField
blob.ShowRecommendedFee = restModel.ShowRecommendedFee;
blob.RecommendedFeeBlockTarget = restModel.RecommendedFeeBlockTarget;
blob.DefaultLang = restModel.DefaultLang;
blob.MonitoringExpiration = (int)restModel.MonitoringExpiration.TotalMinutes;
blob.InvoiceExpiration = (int)restModel.InvoiceExpiration.TotalMinutes;
blob.MonitoringExpiration = restModel.MonitoringExpiration;
blob.InvoiceExpiration = restModel.InvoiceExpiration;
blob.LightningAmountInSatoshi = restModel.LightningAmountInSatoshi;
blob.CustomLogo = restModel.CustomLogo;
blob.CustomCSS = restModel.CustomCSS;

View File

@@ -29,7 +29,7 @@ namespace BTCPayServer.Controllers
[HttpPost]
[Route("invoices")]
[MediaTypeConstraint("application/json")]
public async Task<DataWrapper<InvoiceResponse>> CreateInvoice([FromBody] CreateInvoiceRequest invoice, CancellationToken cancellationToken)
public async Task<DataWrapper<InvoiceResponse>> CreateInvoice([FromBody] BitpayCreateInvoiceRequest invoice, CancellationToken cancellationToken)
{
if (invoice == null)
throw new BitpayHttpException(400, "Invalid invoice");

View File

@@ -30,6 +30,7 @@ using NBitcoin;
using NBitpayClient;
using NBXplorer;
using Newtonsoft.Json.Linq;
using BitpayCreateInvoiceRequest = BTCPayServer.Models.BitpayCreateInvoiceRequest;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers
@@ -52,7 +53,6 @@ namespace BTCPayServer.Controllers
if (invoice == null)
return NotFound();
var prodInfo = invoice.ProductInformation;
var store = await _StoreRepository.FindStore(invoice.StoreId);
var model = new InvoiceDetailsModel()
{
@@ -68,16 +68,14 @@ namespace BTCPayServer.Controllers
CreatedDate = invoice.InvoiceTime,
ExpirationDate = invoice.ExpirationTime,
MonitoringDate = invoice.MonitoringExpiration,
OrderId = invoice.OrderId,
BuyerInformation = invoice.BuyerInformation,
Fiat = _CurrencyNameTable.DisplayFormatCurrency(prodInfo.Price, prodInfo.Currency),
TaxIncluded = _CurrencyNameTable.DisplayFormatCurrency(prodInfo.TaxIncluded, prodInfo.Currency),
Fiat = _CurrencyNameTable.DisplayFormatCurrency(invoice.Price, invoice.Currency),
TaxIncluded = _CurrencyNameTable.DisplayFormatCurrency(invoice.Metadata.TaxIncluded ?? 0.0m, invoice.Currency),
NotificationUrl = invoice.NotificationURL?.AbsoluteUri,
RedirectUrl = invoice.RedirectURL?.AbsoluteUri,
ProductInformation = invoice.ProductInformation,
TypedMetadata = invoice.Metadata,
StatusException = invoice.ExceptionStatus,
Events = invoice.Events,
PosData = PosDataParser.ParsePosData(invoice.PosData),
PosData = PosDataParser.ParsePosData(invoice.Metadata.PosData),
Archived = invoice.Archived,
CanRefund = CanRefund(invoice.GetInvoiceState()),
};
@@ -177,7 +175,7 @@ namespace BTCPayServer.Controllers
if (!CanRefund(invoice.GetInvoiceState()))
return NotFound();
var paymentMethodId = new PaymentMethodId(model.SelectedPaymentMethod, PaymentTypes.BTCLike);
var cdCurrency = _CurrencyNameTable.GetCurrencyData(invoice.ProductInformation.Currency, true);
var cdCurrency = _CurrencyNameTable.GetCurrencyData(invoice.Currency, true);
var paymentMethodDivisibility = _CurrencyNameTable.GetCurrencyData(paymentMethodId.CryptoCode, false)?.Divisibility ?? 8;
if (model.SelectedRefundOption is null)
{
@@ -187,7 +185,7 @@ namespace BTCPayServer.Controllers
model.CryptoAmountThen = Math.Round(paidCurrency / paymentMethod.Rate, paymentMethodDivisibility);
model.RateThenText = _CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountThen, paymentMethodId.CryptoCode, true);
var rules = store.GetStoreBlob().GetRateRules(_NetworkProvider);
var rateResult = await _RateProvider.FetchRate(new Rating.CurrencyPair(paymentMethodId.CryptoCode, invoice.ProductInformation.Currency), rules, cancellationToken);
var rateResult = await _RateProvider.FetchRate(new Rating.CurrencyPair(paymentMethodId.CryptoCode, invoice.Currency), rules, cancellationToken);
//TODO: What if fetching rate failed?
if (rateResult.BidAsk is null)
{
@@ -197,7 +195,7 @@ namespace BTCPayServer.Controllers
model.CryptoAmountNow = Math.Round(paidCurrency / rateResult.BidAsk.Bid, paymentMethodDivisibility);
model.CurrentRateText = _CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountNow, paymentMethodId.CryptoCode, true);
model.FiatAmount = paidCurrency;
model.FiatText = _CurrencyNameTable.DisplayFormatCurrency(model.FiatAmount, invoice.ProductInformation.Currency, true);
model.FiatText = _CurrencyNameTable.DisplayFormatCurrency(model.FiatAmount, invoice.Currency, true);
return View(model);
}
else
@@ -217,7 +215,7 @@ namespace BTCPayServer.Controllers
createPullPayment.Amount = model.CryptoAmountNow;
break;
case "Fiat":
createPullPayment.Currency = invoice.ProductInformation.Currency;
createPullPayment.Currency = invoice.Currency;
createPullPayment.Amount = model.FiatAmount;
break;
default:
@@ -403,7 +401,7 @@ namespace BTCPayServer.Controllers
var dto = invoice.EntityToDTO();
var cryptoInfo = dto.CryptoInfo.First(o => o.GetpaymentMethodId() == paymentMethodId);
var storeBlob = store.GetStoreBlob();
var currency = invoice.ProductInformation.Currency;
var currency = invoice.Currency;
var accounting = paymentMethod.Calculate();
ChangellySettings changelly = (storeBlob.ChangellySettings != null && storeBlob.ChangellySettings.Enabled &&
@@ -425,12 +423,11 @@ namespace BTCPayServer.Controllers
var paymentMethodHandler = _paymentMethodHandlerDictionary[paymentMethodId];
var divisibility = _CurrencyNameTable.GetNumberFormatInfo(paymentMethod.GetId().CryptoCode, false)?.CurrencyDecimalDigits;
var model = new PaymentModel()
{
CryptoCode = network.CryptoCode,
RootPath = this.Request.PathBase.Value.WithTrailingSlash(),
OrderId = invoice.OrderId,
OrderId = invoice.Metadata.OrderId,
InvoiceId = invoice.Id,
DefaultLang = storeBlob.DefaultLang ?? "en",
CustomCSSLink = storeBlob.CustomCSS,
@@ -440,7 +437,7 @@ namespace BTCPayServer.Controllers
BtcAddress = paymentMethodDetails.GetPaymentDestination(),
BtcDue = accounting.Due.ShowMoney(divisibility),
OrderAmount = (accounting.TotalDue - accounting.NetworkFee).ShowMoney(divisibility),
OrderAmountFiat = OrderAmountFromInvoice(network.CryptoCode, invoice.ProductInformation),
OrderAmountFiat = OrderAmountFromInvoice(network.CryptoCode, invoice),
CustomerEmail = invoice.RefundMail,
RequiresRefundEmail = storeBlob.RequiresRefundEmail,
ShowRecommendedFee = storeBlob.ShowRecommendedFee,
@@ -448,7 +445,7 @@ namespace BTCPayServer.Controllers
ExpirationSeconds = Math.Max(0, (int)(invoice.ExpirationTime - DateTimeOffset.UtcNow).TotalSeconds),
MaxTimeSeconds = (int)(invoice.ExpirationTime - invoice.InvoiceTime).TotalSeconds,
MaxTimeMinutes = (int)(invoice.ExpirationTime - invoice.InvoiceTime).TotalMinutes,
ItemDesc = invoice.ProductInformation.ItemDesc,
ItemDesc = invoice.Metadata.ItemDesc,
Rate = ExchangeRate(paymentMethod),
MerchantRefLink = invoice.RedirectURL?.AbsoluteUri ?? "/",
RedirectAutomatically = invoice.RedirectAutomatically,
@@ -498,7 +495,7 @@ namespace BTCPayServer.Controllers
paymentMethodHandler.PreparePaymentModel(model, dto, storeBlob);
if (model.IsLightning && storeBlob.LightningAmountInSatoshi && model.CryptoCode == "Sats")
{
model.Rate = _CurrencyNameTable.DisplayFormatCurrency(paymentMethod.Rate / 100_000_000, paymentMethod.ParentEntity.ProductInformation.Currency);
model.Rate = _CurrencyNameTable.DisplayFormatCurrency(paymentMethod.Rate / 100_000_000, paymentMethod.ParentEntity.Currency);
}
model.UISettings = paymentMethodHandler.GetCheckoutUISettings();
model.PaymentMethodId = paymentMethodId.ToString();
@@ -507,17 +504,17 @@ namespace BTCPayServer.Controllers
return model;
}
private string OrderAmountFromInvoice(string cryptoCode, ProductInformation productInformation)
private string OrderAmountFromInvoice(string cryptoCode, InvoiceEntity invoiceEntity)
{
// if invoice source currency is the same as currently display currency, no need for "order amount from invoice"
if (cryptoCode == productInformation.Currency)
if (cryptoCode == invoiceEntity.Currency)
return null;
return _CurrencyNameTable.DisplayFormatCurrency(productInformation.Price, productInformation.Currency);
return _CurrencyNameTable.DisplayFormatCurrency(invoiceEntity.Price, invoiceEntity.Currency);
}
private string ExchangeRate(PaymentMethod paymentMethod)
{
string currency = paymentMethod.ParentEntity.ProductInformation.Currency;
string currency = paymentMethod.ParentEntity.Currency;
return _CurrencyNameTable.DisplayFormatCurrency(paymentMethod.Rate, currency);
}
@@ -626,9 +623,9 @@ namespace BTCPayServer.Controllers
ShowCheckout = invoice.Status == InvoiceStatus.New,
Date = invoice.InvoiceTime,
InvoiceId = invoice.Id,
OrderId = invoice.OrderId ?? string.Empty,
OrderId = invoice.Metadata.OrderId ?? string.Empty,
RedirectUrl = invoice.RedirectURL?.AbsoluteUri ?? string.Empty,
AmountCurrency = _CurrencyNameTable.DisplayFormatCurrency(invoice.ProductInformation.Price, invoice.ProductInformation.Currency),
AmountCurrency = _CurrencyNameTable.DisplayFormatCurrency(invoice.Price, invoice.Currency),
CanMarkInvalid = state.CanMarkInvalid(),
CanMarkComplete = state.CanMarkComplete(),
Details = InvoicePopulatePayments(invoice),
@@ -730,7 +727,7 @@ namespace BTCPayServer.Controllers
try
{
var result = await CreateInvoiceCore(new CreateInvoiceRequest()
var result = await CreateInvoiceCore(new BitpayCreateInvoiceRequest()
{
Price = model.Amount.Value,
Currency = model.Currency,
@@ -776,14 +773,12 @@ namespace BTCPayServer.Controllers
}
if (newState == "invalid")
{
await _InvoiceRepository.UpdatePaidInvoiceToInvalid(invoiceId);
_EventAggregator.Publish(new InvoiceEvent(invoice, 1008, InvoiceEvent.MarkedInvalid));
await _InvoiceRepository.MarkInvoiceStatus(invoiceId, InvoiceStatus.Invalid);
model.StatusString = new InvoiceState("invalid", "marked").ToString();
}
else if (newState == "complete")
{
await _InvoiceRepository.UpdatePaidInvoiceToComplete(invoiceId);
_EventAggregator.Publish(new InvoiceEvent(invoice, 2008, InvoiceEvent.MarkedCompleted));
await _InvoiceRepository.MarkInvoiceStatus(invoiceId, InvoiceStatus.Complete);
model.StatusString = new InvoiceState("complete", "marked").ToString();
}

View File

@@ -21,9 +21,10 @@ using BTCPayServer.Services.Stores;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using NBitpayClient;
using Newtonsoft.Json;
using CreateInvoiceRequest = BTCPayServer.Models.CreateInvoiceRequest;
using BitpayCreateInvoiceRequest = BTCPayServer.Models.BitpayCreateInvoiceRequest;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers
@@ -72,40 +73,37 @@ namespace BTCPayServer.Controllers
}
internal async Task<DataWrapper<InvoiceResponse>> CreateInvoiceCore(CreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string> additionalTags = null, CancellationToken cancellationToken = default)
internal async Task<DataWrapper<InvoiceResponse>> CreateInvoiceCore(BitpayCreateInvoiceRequest invoice,
StoreData store, string serverUrl, List<string> additionalTags = null,
CancellationToken cancellationToken = default)
{
invoice.Currency = invoice.Currency?.ToUpperInvariant() ?? "USD";
InvoiceLogs logs = new InvoiceLogs();
logs.Write("Creation of invoice starting");
var entity = _InvoiceRepository.CreateNewInvoice();
var entity = await CreateInvoiceCoreRaw(invoice, store, serverUrl, additionalTags, cancellationToken);
var resp = entity.EntityToDTO();
return new DataWrapper<InvoiceResponse>(resp) { Facade = "pos/invoice" };
}
var getAppsTaggingStore = _InvoiceRepository.GetAppsTaggingStore(store.Id);
internal async Task<InvoiceEntity> CreateInvoiceCoreRaw(BitpayCreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string> additionalTags = null, CancellationToken cancellationToken = default)
{
var storeBlob = store.GetStoreBlob();
EmailAddressAttribute emailValidator = new EmailAddressAttribute();
entity.ExpirationTime = invoice.ExpirationTime is DateTimeOffset v ? v : entity.InvoiceTime.AddMinutes(storeBlob.InvoiceExpiration);
var entity = _InvoiceRepository.CreateNewInvoice();
entity.ExpirationTime = invoice.ExpirationTime is DateTimeOffset v ? v : entity.InvoiceTime + storeBlob.InvoiceExpiration;
entity.MonitoringExpiration = entity.ExpirationTime + storeBlob.MonitoringExpiration;
if (entity.ExpirationTime - TimeSpan.FromSeconds(30.0) < entity.InvoiceTime)
{
throw new BitpayHttpException(400, "The expirationTime is set too soon");
}
entity.MonitoringExpiration = entity.ExpirationTime + TimeSpan.FromMinutes(storeBlob.MonitoringExpiration);
entity.OrderId = invoice.OrderId;
invoice.Currency = invoice.Currency?.ToUpperInvariant() ?? "USD";
entity.Currency = invoice.Currency;
entity.Metadata.OrderId = invoice.OrderId;
entity.Metadata.PosData = invoice.PosData;
entity.ServerUrl = serverUrl;
entity.FullNotifications = invoice.FullNotifications || invoice.ExtendedNotifications;
entity.ExtendedNotifications = invoice.ExtendedNotifications;
entity.NotificationURLTemplate = invoice.NotificationURL;
entity.NotificationEmail = invoice.NotificationEmail;
entity.BuyerInformation = Map<CreateInvoiceRequest, BuyerInformation>(invoice);
entity.PaymentTolerance = storeBlob.PaymentTolerance;
if (additionalTags != null)
entity.InternalTags.AddRange(additionalTags);
//Another way of passing buyer info to support
FillBuyerInfo(invoice.Buyer, entity.BuyerInformation);
if (entity?.BuyerInformation?.BuyerEmail != null)
{
if (!EmailValidator.IsEmail(entity.BuyerInformation.BuyerEmail))
throw new BitpayHttpException(400, "Invalid email");
entity.RefundMail = entity.BuyerInformation.BuyerEmail;
}
FillBuyerInfo(invoice, entity);
var taxIncluded = invoice.TaxIncluded.HasValue ? invoice.TaxIncluded.Value : 0m;
@@ -120,22 +118,19 @@ namespace BTCPayServer.Controllers
invoice.Price = Math.Max(0.0m, invoice.Price);
invoice.TaxIncluded = Math.Max(0.0m, taxIncluded);
invoice.TaxIncluded = Math.Min(taxIncluded, invoice.Price);
entity.ProductInformation = Map<CreateInvoiceRequest, ProductInformation>(invoice);
entity.Metadata.ItemCode = invoice.ItemCode;
entity.Metadata.ItemDesc = invoice.ItemDesc;
entity.Metadata.Physical = invoice.Physical;
entity.Metadata.TaxIncluded = invoice.TaxIncluded;
entity.Currency = invoice.Currency;
entity.Price = invoice.Price;
entity.RedirectURLTemplate = invoice.RedirectURL ?? store.StoreWebsite;
entity.RedirectAutomatically =
invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically);
entity.Status = InvoiceStatus.New;
entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy);
HashSet<CurrencyPair> currencyPairsToFetch = new HashSet<CurrencyPair>();
var rules = storeBlob.GetRateRules(_NetworkProvider);
var excludeFilter = storeBlob.GetExcludedPaymentMethods(); // Here we can compose filters from other origin with PaymentFilter.Any()
IPaymentFilter excludeFilter = null;
if (invoice.PaymentCurrencies?.Any() is true)
{
invoice.SupportedTransactionCurrencies ??=
@@ -151,17 +146,67 @@ namespace BTCPayServer.Controllers
var supportedTransactionCurrencies = invoice.SupportedTransactionCurrencies
.Where(c => c.Value.Enabled)
.Select(c => PaymentMethodId.TryParse(c.Key, out var p) ? p : null)
.Where(c => c != null)
.ToHashSet();
excludeFilter = PaymentFilter.Or(excludeFilter,
PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p)));
excludeFilter = PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p));
}
entity.PaymentTolerance = storeBlob.PaymentTolerance;
return await CreateInvoiceCoreRaw(entity, store, excludeFilter, cancellationToken);
}
internal async Task<InvoiceEntity> CreateInvoiceCoreRaw(CreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string> additionalTags = null, CancellationToken cancellationToken = default)
{
var storeBlob = store.GetStoreBlob();
var entity = _InvoiceRepository.CreateNewInvoice();
entity.ExpirationTime = entity.InvoiceTime + (invoice.Checkout.Expiration ?? storeBlob.InvoiceExpiration);
entity.MonitoringExpiration = entity.ExpirationTime + (invoice.Checkout.Monitoring ?? storeBlob.MonitoringExpiration);
if (invoice.Metadata != null)
entity.Metadata = InvoiceMetadata.FromJObject(invoice.Metadata);
invoice.Checkout ??= new CreateInvoiceRequest.CheckoutOptions();
entity.Currency = invoice.Currency;
entity.Price = invoice.Amount;
entity.SpeedPolicy = invoice.Checkout.SpeedPolicy ?? store.SpeedPolicy;
IPaymentFilter excludeFilter = null;
if (invoice.Checkout.PaymentMethods != null)
{
var supportedTransactionCurrencies = invoice.Checkout.PaymentMethods
.Select(c => PaymentMethodId.TryParse(c, out var p) ? p : null)
.ToHashSet();
excludeFilter = PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p));
}
entity.PaymentTolerance = invoice.Checkout.PaymentTolerance ?? storeBlob.PaymentTolerance;
return await CreateInvoiceCoreRaw(entity, store, excludeFilter, cancellationToken);
}
internal async Task<InvoiceEntity> CreateInvoiceCoreRaw(InvoiceEntity entity, StoreData store, IPaymentFilter invoicePaymentMethodFilter, CancellationToken cancellationToken = default)
{
InvoiceLogs logs = new InvoiceLogs();
logs.Write("Creation of invoice starting");
var getAppsTaggingStore = _InvoiceRepository.GetAppsTaggingStore(store.Id);
var storeBlob = store.GetStoreBlob();
if (entity.Metadata.BuyerEmail != null)
{
if (!EmailValidator.IsEmail(entity.Metadata.BuyerEmail))
throw new BitpayHttpException(400, "Invalid email");
entity.RefundMail = entity.Metadata.BuyerEmail;
}
entity.Status = InvoiceStatus.New;
HashSet<CurrencyPair> currencyPairsToFetch = new HashSet<CurrencyPair>();
var rules = storeBlob.GetRateRules(_NetworkProvider);
var excludeFilter = storeBlob.GetExcludedPaymentMethods(); // Here we can compose filters from other origin with PaymentFilter.Any()
if (invoicePaymentMethodFilter != null)
{
excludeFilter = PaymentFilter.Or(excludeFilter,
invoicePaymentMethodFilter);
}
foreach (var network in store.GetSupportedPaymentMethods(_NetworkProvider)
.Where(s => !excludeFilter.Match(s.PaymentId))
.Select(c => _NetworkProvider.GetNetwork<BTCPayNetworkBase>(c.PaymentId.CryptoCode))
.Where(c => c != null))
{
currencyPairsToFetch.Add(new CurrencyPair(network.CryptoCode, invoice.Currency));
currencyPairsToFetch.Add(new CurrencyPair(network.CryptoCode, entity.Currency));
//TODO: abstract
if (storeBlob.LightningMaxValue != null)
currencyPairsToFetch.Add(new CurrencyPair(network.CryptoCode, storeBlob.LightningMaxValue.Currency));
@@ -173,7 +218,12 @@ namespace BTCPayServer.Controllers
var fetchingByCurrencyPair = _RateProvider.FetchRates(currencyPairsToFetch, rateRules, cancellationToken);
var fetchingAll = WhenAllFetched(logs, fetchingByCurrencyPair);
var supportedPaymentMethods = store.GetSupportedPaymentMethods(_NetworkProvider)
List<ISupportedPaymentMethod> supported = new List<ISupportedPaymentMethod>();
var paymentMethods = new PaymentMethodDictionary();
// This loop ends with .ToList so we are querying all payment methods at once
// instead of sequentially to improve response time
foreach (var o in store.GetSupportedPaymentMethods(_NetworkProvider)
.Where(s => !excludeFilter.Match(s.PaymentId) && _paymentMethodHandlerDictionary.Support(s.PaymentId))
.Select(c =>
(Handler: _paymentMethodHandlerDictionary[c.PaymentId],
@@ -183,10 +233,7 @@ namespace BTCPayServer.Controllers
.Select(o =>
(SupportedPaymentMethod: o.SupportedPaymentMethod,
PaymentMethod: CreatePaymentMethodAsync(fetchingByCurrencyPair, o.Handler, o.SupportedPaymentMethod, o.Network, entity, store, logs)))
.ToList();
List<ISupportedPaymentMethod> supported = new List<ISupportedPaymentMethod>();
var paymentMethods = new PaymentMethodDictionary();
foreach (var o in supportedPaymentMethods)
.ToList())
{
var paymentMethod = await o.PaymentMethod;
if (paymentMethod == null)
@@ -209,8 +256,6 @@ namespace BTCPayServer.Controllers
entity.SetSupportedPaymentMethods(supported);
entity.SetPaymentMethods(paymentMethods);
entity.PosData = invoice.PosData;
foreach (var app in await getAppsTaggingStore)
{
entity.InternalTags.Add(AppService.GetAppInternalTag(app.Id));
@@ -232,9 +277,8 @@ namespace BTCPayServer.Controllers
}
await _InvoiceRepository.AddInvoiceLogs(entity.Id, logs);
});
_EventAggregator.Publish(new Events.InvoiceEvent(entity, 1001, InvoiceEvent.Created));
var resp = entity.EntityToDTO();
return new DataWrapper<InvoiceResponse>(resp) { Facade = "pos/invoice" };
_EventAggregator.Publish(new Events.InvoiceEvent(entity, InvoiceEvent.Created));
return entity;
}
private Task WhenAllFetched(InvoiceLogs logs, Dictionary<CurrencyPair, Task<RateResult>> fetchingByCurrencyPair)
@@ -263,7 +307,7 @@ namespace BTCPayServer.Controllers
var logPrefix = $"{supportedPaymentMethod.PaymentId.ToPrettyString()}:";
var storeBlob = store.GetStoreBlob();
var preparePayment = handler.PreparePayment(supportedPaymentMethod, store, network);
var rate = await fetchingByCurrencyPair[new CurrencyPair(network.CryptoCode, entity.ProductInformation.Currency)];
var rate = await fetchingByCurrencyPair[new CurrencyPair(network.CryptoCode, entity.Currency)];
if (rate.BidAsk == null)
{
return null;
@@ -327,24 +371,30 @@ namespace BTCPayServer.Controllers
return policy;
}
private void FillBuyerInfo(Buyer buyer, BuyerInformation buyerInformation)
private void FillBuyerInfo(BitpayCreateInvoiceRequest req, InvoiceEntity invoiceEntity)
{
var buyerInformation = invoiceEntity.Metadata;
buyerInformation.BuyerAddress1 = req.BuyerAddress1;
buyerInformation.BuyerAddress2 = req.BuyerAddress2;
buyerInformation.BuyerCity = req.BuyerCity;
buyerInformation.BuyerCountry = req.BuyerCountry;
buyerInformation.BuyerEmail = req.BuyerEmail;
buyerInformation.BuyerName = req.BuyerName;
buyerInformation.BuyerPhone = req.BuyerPhone;
buyerInformation.BuyerState = req.BuyerState;
buyerInformation.BuyerZip = req.BuyerZip;
var buyer = req.Buyer;
if (buyer == null)
return;
buyerInformation.BuyerAddress1 = buyerInformation.BuyerAddress1 ?? buyer.Address1;
buyerInformation.BuyerAddress2 = buyerInformation.BuyerAddress2 ?? buyer.Address2;
buyerInformation.BuyerCity = buyerInformation.BuyerCity ?? buyer.City;
buyerInformation.BuyerCountry = buyerInformation.BuyerCountry ?? buyer.country;
buyerInformation.BuyerEmail = buyerInformation.BuyerEmail ?? buyer.email;
buyerInformation.BuyerName = buyerInformation.BuyerName ?? buyer.Name;
buyerInformation.BuyerPhone = buyerInformation.BuyerPhone ?? buyer.phone;
buyerInformation.BuyerState = buyerInformation.BuyerState ?? buyer.State;
buyerInformation.BuyerZip = buyerInformation.BuyerZip ?? buyer.zip;
}
private TDest Map<TFrom, TDest>(TFrom data)
{
return JsonConvert.DeserializeObject<TDest>(JsonConvert.SerializeObject(data));
buyerInformation.BuyerAddress1 ??= buyer.Address1;
buyerInformation.BuyerAddress2 ??= buyer.Address2;
buyerInformation.BuyerCity ??= buyer.City;
buyerInformation.BuyerCountry ??= buyer.country;
buyerInformation.BuyerEmail ??= buyer.email;
buyerInformation.BuyerName ??= buyer.Name;
buyerInformation.BuyerPhone ??= buyer.phone;
buyerInformation.BuyerState ??= buyer.State;
buyerInformation.BuyerZip ??= buyer.zip;
}
}
}

View File

@@ -363,6 +363,8 @@ namespace BTCPayServer.Controllers
{BTCPayServer.Client.Policies.CanModifyProfile, ("Manage your profile", "The app will be able to view and modify your user profile.")},
{BTCPayServer.Client.Policies.CanCreateInvoice, ("Create an invoice", "The app will be able to create new invoices.")},
{$"{BTCPayServer.Client.Policies.CanCreateInvoice}:", ("Create an invoice", "The app will be able to create new invoices on the selected stores.")},
{BTCPayServer.Client.Policies.CanViewInvoices, ("View invoices", "The app will be able to view invoices.")},
{$"{BTCPayServer.Client.Policies.CanViewInvoices}:", ("View invoices", "The app will be able to view invoices on the selected stores.")},
{BTCPayServer.Client.Policies.CanModifyPaymentRequests, ("Modify your payment requests", "The app will be able to view, modify, delete and create new payment requests on all your stores.")},
{$"{BTCPayServer.Client.Policies.CanModifyPaymentRequests}:", ("Manage selected stores' payment requests", "The app will be able to view, modify, delete and create new payment requests on the selected stores.")},
{BTCPayServer.Client.Policies.CanViewPaymentRequests, ("View your payment requests", "The app will be able to view payment requests.")},

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Filters;
@@ -19,6 +20,9 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Routing;
using BitpayCreateInvoiceRequest = BTCPayServer.Models.BitpayCreateInvoiceRequest;
using PaymentRequestData = BTCPayServer.Data.PaymentRequestData;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers
{
@@ -256,7 +260,7 @@ namespace BTCPayServer.Controllers
try
{
var redirectUrl = _linkGenerator.PaymentRequestLink(id, Request.Scheme, Request.Host, Request.PathBase);
var newInvoiceId = (await _InvoiceController.CreateInvoiceCore(new CreateInvoiceRequest()
var newInvoiceId = (await _InvoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest()
{
OrderId = $"{PaymentRequestRepository.GetOrderIdForPaymentRequest(id)}",
Currency = blob.Currency,
@@ -303,9 +307,7 @@ namespace BTCPayServer.Controllers
foreach (var invoice in invoices)
{
await _InvoiceRepository.UpdatePaidInvoiceToInvalid(invoice.Id);
_EventAggregator.Publish(new InvoiceEvent(await _InvoiceRepository.GetInvoice(invoice.Id), 1008,
InvoiceEvent.MarkedInvalid));
await _InvoiceRepository.MarkInvoiceStatus(invoice.Id, InvoiceStatus.Invalid);
}
if (redirect)

View File

@@ -57,7 +57,7 @@ namespace BTCPayServer.Controllers
DataWrapper<InvoiceResponse> invoice = null;
try
{
invoice = await _InvoiceController.CreateInvoiceCore(new CreateInvoiceRequest()
invoice = await _InvoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest()
{
Price = model.Price,
Currency = model.Currency,

View File

@@ -481,8 +481,8 @@ namespace BTCPayServer.Controllers
vm.SpeedPolicy = store.SpeedPolicy;
vm.CanDelete = _Repo.CanDeleteStores();
AddPaymentMethods(store, storeBlob, vm);
vm.MonitoringExpiration = storeBlob.MonitoringExpiration;
vm.InvoiceExpiration = storeBlob.InvoiceExpiration;
vm.MonitoringExpiration = (int)storeBlob.MonitoringExpiration.TotalMinutes;
vm.InvoiceExpiration = (int)storeBlob.InvoiceExpiration.TotalMinutes;
vm.LightningDescriptionTemplate = storeBlob.LightningDescriptionTemplate;
vm.PaymentTolerance = storeBlob.PaymentTolerance;
vm.PayJoinEnabled = storeBlob.PayJoinEnabled;
@@ -579,8 +579,8 @@ namespace BTCPayServer.Controllers
var blob = CurrentStore.GetStoreBlob();
blob.AnyoneCanInvoice = model.AnyoneCanCreateInvoice;
blob.NetworkFeeMode = model.NetworkFeeMode;
blob.MonitoringExpiration = model.MonitoringExpiration;
blob.InvoiceExpiration = model.InvoiceExpiration;
blob.MonitoringExpiration = TimeSpan.FromMinutes(model.MonitoringExpiration);
blob.InvoiceExpiration = TimeSpan.FromMinutes(model.InvoiceExpiration);
blob.LightningDescriptionTemplate = model.LightningDescriptionTemplate ?? string.Empty;
blob.PaymentTolerance = model.PaymentTolerance;
var payjoinChanged = blob.PayJoinEnabled != model.PayJoinEnabled;

View File

@@ -1,4 +1,5 @@
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Authorization.Infrastructure;
namespace BTCPayServer.Data
{
@@ -8,6 +9,17 @@ namespace BTCPayServer.Data
{
var entity = NBitcoin.JsonConverters.Serializer.ToObject<InvoiceEntity>(ZipUtils.Unzip(invoiceData.Blob), null);
entity.Networks = networks;
if (entity.Metadata is null)
{
if (entity.Version < InvoiceEntity.GreenfieldInvoices_Version)
{
entity.MigrateLegacyInvoice();
}
else
{
entity.Metadata = new InvoiceMetadata();
}
}
return entity;
}
public static InvoiceState GetInvoiceState(this InvoiceData invoiceData)

View File

@@ -195,7 +195,7 @@ namespace BTCPayServer.Data
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal MinimumClaim { get; set; }
public PullPaymentView View { get; set; } = new PullPaymentView();
[JsonConverter(typeof(TimeSpanJsonConverter))]
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
public TimeSpan? Period { get; set; }
[JsonProperty(ItemConverterType = typeof(PaymentMethodIdJsonConverter))]

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using BTCPayServer.Client.JsonConverters;
using BTCPayServer.Client.Models;
using BTCPayServer.JsonConverters;
using BTCPayServer.Payments;
@@ -19,8 +20,8 @@ namespace BTCPayServer.Data
{
public StoreBlob()
{
InvoiceExpiration = 15;
MonitoringExpiration = 1440;
InvoiceExpiration = TimeSpan.FromMinutes(15);
MonitoringExpiration = TimeSpan.FromDays(1);
PaymentTolerance = 0;
ShowRecommendedFee = true;
RecommendedFeeBlockTarget = 1;
@@ -66,17 +67,19 @@ namespace BTCPayServer.Data
}
public string DefaultLang { get; set; }
[DefaultValue(60)]
[DefaultValue(typeof(TimeSpan), "1.00:00:00")]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public int MonitoringExpiration
[JsonConverter(typeof(TimeSpanJsonConverter.Minutes))]
public TimeSpan MonitoringExpiration
{
get;
set;
}
[DefaultValue(15)]
[DefaultValue(typeof(TimeSpan), "00:15:00")]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public int InvoiceExpiration
[JsonConverter(typeof(TimeSpanJsonConverter.Minutes))]
public TimeSpan InvoiceExpiration
{
get;
set;

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using BTCPayServer.Services.Invoices;
namespace BTCPayServer.Events
@@ -15,11 +16,26 @@ namespace BTCPayServer.Events
public const string FailedToConfirm = "invoice_failedToConfirm";
public const string Confirmed = "invoice_confirmed";
public const string Completed = "invoice_completed";
public static Dictionary<string, int> EventCodes = new Dictionary<string, int>()
{
{Created, 1001},
{ReceivedPayment, 1002},
{PaidInFull, 1003},
{Expired, 1004},
{Confirmed, 1005},
{Completed, 1006},
{MarkedInvalid, 1008},
{FailedToConfirm, 1013},
{PaidAfterExpiration, 1009},
{ExpiredPaidPartial, 2000},
{MarkedCompleted, 2008},
};
public InvoiceEvent(InvoiceEntity invoice, int code, string name)
public InvoiceEvent(InvoiceEntity invoice, string name)
{
Invoice = invoice;
EventCode = code;
EventCode = EventCodes[name];
Name = name;
}

View File

@@ -104,9 +104,8 @@ namespace BTCPayServer.HostedServices
return;
}
if ((!string.IsNullOrEmpty(invoiceEvent.Invoice.ProductInformation.ItemCode) ||
AppService.TryParsePosCartItems(invoiceEvent.Invoice.PosData, out cartItems)))
if ((!string.IsNullOrEmpty(invoiceEvent.Invoice.Metadata.ItemCode) ||
AppService.TryParsePosCartItems(invoiceEvent.Invoice.Metadata.PosData, out cartItems)))
{
var appIds = AppService.GetAppInternalTags(invoiceEvent.Invoice);
@@ -116,9 +115,9 @@ namespace BTCPayServer.HostedServices
}
var items = cartItems ?? new Dictionary<string, int>();
if (!string.IsNullOrEmpty(invoiceEvent.Invoice.ProductInformation.ItemCode))
if (!string.IsNullOrEmpty(invoiceEvent.Invoice.Metadata.ItemCode))
{
items.TryAdd(invoiceEvent.Invoice.ProductInformation.ItemCode, 1);
items.TryAdd(invoiceEvent.Invoice.Metadata.ItemCode, 1);
}
_eventAggregator.Publish(new UpdateAppInventory()

View File

@@ -5,6 +5,7 @@ using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Events;
using BTCPayServer.Payments;
using BTCPayServer.Services;

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Events;
using BTCPayServer.Logging;
using BTCPayServer.Services.Invoices;
@@ -65,9 +66,9 @@ namespace BTCPayServer.HostedServices
await _InvoiceRepository.UnaffectAddress(invoice.Id);
invoice.Status = InvoiceStatus.Expired;
context.Events.Add(new InvoiceEvent(invoice, 1004, InvoiceEvent.Expired));
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.Expired));
if (invoice.ExceptionStatus == InvoiceExceptionStatus.PaidPartial)
context.Events.Add(new InvoiceEvent(invoice, 2000, InvoiceEvent.ExpiredPaidPartial));
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.ExpiredPaidPartial));
}
var payments = invoice.GetPayments().Where(p => p.Accounted).ToArray();
@@ -81,7 +82,7 @@ namespace BTCPayServer.HostedServices
{
if (invoice.Status == InvoiceStatus.New)
{
context.Events.Add(new InvoiceEvent(invoice, 1003, InvoiceEvent.PaidInFull));
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.PaidInFull));
invoice.Status = InvoiceStatus.Paid;
invoice.ExceptionStatus = accounting.Paid > accounting.TotalDue ? InvoiceExceptionStatus.PaidOver : InvoiceExceptionStatus.None;
await _InvoiceRepository.UnaffectAddress(invoice.Id);
@@ -90,7 +91,7 @@ namespace BTCPayServer.HostedServices
else if (invoice.Status == InvoiceStatus.Expired && invoice.ExceptionStatus != InvoiceExceptionStatus.PaidLate)
{
invoice.ExceptionStatus = InvoiceExceptionStatus.PaidLate;
context.Events.Add(new InvoiceEvent(invoice, 1009, InvoiceEvent.PaidAfterExpiration));
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.PaidAfterExpiration));
context.MarkDirty();
}
}
@@ -136,7 +137,7 @@ namespace BTCPayServer.HostedServices
(confirmedAccounting.Paid < accounting.MinimumTotalDue))
{
await _InvoiceRepository.UnaffectAddress(invoice.Id);
context.Events.Add(new InvoiceEvent(invoice, 1013, InvoiceEvent.FailedToConfirm));
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.FailedToConfirm));
invoice.Status = InvoiceStatus.Invalid;
context.MarkDirty();
}
@@ -144,7 +145,7 @@ namespace BTCPayServer.HostedServices
{
await _InvoiceRepository.UnaffectAddress(invoice.Id);
invoice.Status = InvoiceStatus.Confirmed;
context.Events.Add(new InvoiceEvent(invoice, 1005, InvoiceEvent.Confirmed));
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.Confirmed));
context.MarkDirty();
}
}
@@ -154,7 +155,7 @@ namespace BTCPayServer.HostedServices
var completedAccounting = paymentMethod.Calculate(p => p.GetCryptoPaymentData().PaymentCompleted(p));
if (completedAccounting.Paid >= accounting.MinimumTotalDue)
{
context.Events.Add(new InvoiceEvent(invoice, 1006, InvoiceEvent.Completed));
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.Completed));
invoice.Status = InvoiceStatus.Complete;
context.MarkDirty();
}

View File

@@ -96,7 +96,7 @@ namespace BTCPayServer.Hosting
var dbpath = Path.Combine(opts.DataDir, "InvoiceDB");
if (!Directory.Exists(dbpath))
Directory.CreateDirectory(dbpath);
return new InvoiceRepository(dbContext, dbpath, o.GetRequiredService<BTCPayNetworkProvider>());
return new InvoiceRepository(dbContext, dbpath, o.GetRequiredService<BTCPayNetworkProvider>(), o.GetService<EventAggregator>());
});
services.AddSingleton<BTCPayServerEnvironment>();
services.TryAddSingleton<TokenRepository>();

View File

@@ -2,10 +2,11 @@ using System;
using System.Collections.Generic;
using NBitpayClient;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Models
{
public class CreateInvoiceRequest
public class BitpayCreateInvoiceRequest
{
[JsonProperty(PropertyName = "buyer")]
public Buyer Buyer { get; set; }

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Services.Invoices;
@@ -73,22 +74,12 @@ namespace BTCPayServer.Models.InvoicingModels
{
get; set;
}
public string OrderId
{
get; set;
}
public string RefundEmail
{
get;
set;
}
public string TaxIncluded { get; set; }
public BuyerInformation BuyerInformation
{
get;
set;
}
public string TransactionSpeed { get; set; }
public object StoreName
@@ -113,11 +104,7 @@ namespace BTCPayServer.Models.InvoicingModels
get;
set;
}
public ProductInformation ProductInformation
{
get;
internal set;
}
public InvoiceMetadata TypedMetadata { get; set; }
public AddressModel[] Addresses { get; set; }
public DateTimeOffset MonitoringDate { get; internal set; }
public List<Data.InvoiceEventData> Events { get; internal set; }

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Invoices;
namespace BTCPayServer.Models.InvoicingModels

View File

@@ -1,6 +1,7 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.Payments;
@@ -9,6 +10,7 @@ using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.PaymentRequests;
using BTCPayServer.Services.Rates;
using Microsoft.AspNetCore.SignalR;
using PaymentRequestData = BTCPayServer.Data.PaymentRequestData;
namespace BTCPayServer.PaymentRequest
{
@@ -99,9 +101,9 @@ namespace BTCPayServer.PaymentRequest
Invoices = invoices.Select(entity => new ViewPaymentRequestViewModel.PaymentRequestInvoice()
{
Id = entity.Id,
Amount = entity.ProductInformation.Price,
AmountFormatted = _currencies.FormatCurrency(entity.ProductInformation.Price, blob.Currency),
Currency = entity.ProductInformation.Currency,
Amount = entity.Price,
AmountFormatted = _currencies.FormatCurrency(entity.Price, blob.Currency),
Currency = entity.Currency,
ExpiryDate = entity.ExpirationTime.DateTime,
Status = entity.GetInvoiceState().ToString(),
Payments = entity

View File

@@ -406,7 +406,7 @@ namespace BTCPayServer.Payments.Bitcoin
invoice.SetPaymentMethod(paymentMethod);
}
wallet.InvalidateCache(strategy);
_Aggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) { Payment = payment });
_Aggregator.Publish(new InvoiceEvent(invoice, InvoiceEvent.ReceivedPayment) { Payment = payment });
return invoice;
}
public async Task StopAsync(CancellationToken cancellationToken)

View File

@@ -48,7 +48,7 @@ namespace BTCPayServer.Payments.Lightning
var storeBlob = store.GetStoreBlob();
var test = GetNodeInfo(paymentMethod.PreferOnion, supportedPaymentMethod, network);
var invoice = paymentMethod.ParentEntity;
var due = Extensions.RoundUp(invoice.ProductInformation.Price / paymentMethod.Rate, network.Divisibility);
var due = Extensions.RoundUp(invoice.Price / paymentMethod.Rate, network.Divisibility);
var client = _lightningClientFactory.Create(supportedPaymentMethod.GetLightningUrl(), network);
var expiry = invoice.ExpirationTime - DateTimeOffset.UtcNow;
if (expiry < TimeSpan.Zero)
@@ -58,8 +58,8 @@ namespace BTCPayServer.Payments.Lightning
string description = storeBlob.LightningDescriptionTemplate;
description = description.Replace("{StoreName}", store.StoreName ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{ItemDescription}", invoice.ProductInformation.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{OrderId}", invoice.OrderId ?? "", StringComparison.OrdinalIgnoreCase);
.Replace("{ItemDescription}", invoice.Metadata.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{OrderId}", invoice.Metadata.OrderId ?? "", StringComparison.OrdinalIgnoreCase);
using (var cts = new CancellationTokenSource(LIGHTNING_TIMEOUT))
{
try

View File

@@ -329,7 +329,7 @@ namespace BTCPayServer.Payments.Lightning
{
var invoice = await invoiceRepository.GetInvoice(invoiceId);
if (invoice != null)
_eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) { Payment = payment });
_eventAggregator.Publish(new InvoiceEvent(invoice, InvoiceEvent.ReceivedPayment) { Payment = payment });
}
return payment != null;
}

View File

@@ -454,7 +454,7 @@ namespace BTCPayServer.Payments.PayJoin
$"The original transaction has already been accounted"));
}
await _btcPayWalletProvider.GetWallet(network).SaveOffchainTransactionAsync(ctx.OriginalTransaction);
_eventAggregator.Publish(new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) { Payment = payment });
_eventAggregator.Publish(new InvoiceEvent(invoice,InvoiceEvent.ReceivedPayment) { Payment = payment });
_eventAggregator.Publish(new UpdateTransactionLabel()
{
WalletId = new WalletId(invoice.StoreId, network.CryptoCode),

View File

@@ -64,20 +64,38 @@ namespace BTCPayServer.Payments
//BTCLike case is special because it is in legacy mode.
return PaymentType == PaymentTypes.BTCLike ? CryptoCode : $"{CryptoCode}_{PaymentType}";
}
/// <summary>
/// A string we can expose to Greenfield API, not subjected to internal legacy
/// </summary>
/// <returns></returns>
public string ToStringNormalized()
{
if (PaymentType == PaymentTypes.BTCLike)
return CryptoCode;
#if ALTCOINS
if (CryptoCode == "XMR" && PaymentType == PaymentTypes.MoneroLike)
return CryptoCode;
#endif
return $"{CryptoCode}-{PaymentType.ToStringNormalized()}";
}
public string ToPrettyString()
{
return $"{CryptoCode} ({PaymentType.ToPrettyString()})";
}
static char[] Separators = new[] { '_', '-' };
public static bool TryParse(string str, out PaymentMethodId paymentMethodId)
{
str ??= "";
paymentMethodId = null;
var parts = str.Split('_', StringSplitOptions.RemoveEmptyEntries);
var parts = str.Split(Separators, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0 || parts.Length > 2)
return false;
PaymentType type = PaymentTypes.BTCLike;
#if ALTCOINS
if (parts[0].ToUpperInvariant() == "XMR")
type = PaymentTypes.MoneroLike;
#endif
if (parts.Length == 2)
{
if (!PaymentTypes.TryParse(parts[1], out type))

View File

@@ -19,6 +19,10 @@ namespace BTCPayServer.Payments
public override string ToPrettyString() => "On-Chain";
public override string GetId() => "BTCLike";
public override string ToStringNormalized()
{
return "OnChain";
}
public override CryptoPaymentData DeserializePaymentData(BTCPayNetworkBase network, string str)
{

View File

@@ -17,7 +17,10 @@ namespace BTCPayServer.Payments
public override string ToPrettyString() => "Off-Chain";
public override string GetId() => "LightningLike";
public override string ToStringNormalized()
{
return "LightningNetwork";
}
public override CryptoPaymentData DeserializePaymentData(BTCPayNetworkBase network, string str)
{
return ((BTCPayNetwork)network)?.ToObject<LightningLikePaymentData>(str);

View File

@@ -22,6 +22,13 @@ namespace BTCPayServer.Payments
/// </summary>
public static LightningPaymentType LightningLike => LightningPaymentType.Instance;
#if ALTCOINS
/// <summary>
/// Monero payment
/// </summary>
public static MoneroPaymentType MoneroLike => MoneroPaymentType.Instance;
#endif
public static bool TryParse(string paymentType, out PaymentType type)
{
switch (paymentType.ToLowerInvariant())
@@ -36,7 +43,7 @@ namespace BTCPayServer.Payments
break;
#if ALTCOINS
case "monerolike":
type = MoneroPaymentType.Instance;
type = PaymentTypes.MoneroLike;
break;
#endif
default:
@@ -61,6 +68,15 @@ namespace BTCPayServer.Payments
return GetId();
}
/// <summary>
/// A string we can expose to Greenfield API, not subjected to internal legacy
/// </summary>
/// <returns></returns>
public virtual string ToStringNormalized()
{
return ToString();
}
public abstract string GetId();
public abstract CryptoPaymentData DeserializePaymentData(BTCPayNetworkBase network, string str);
public abstract string SerializePaymentData(BTCPayNetworkBase network, CryptoPaymentData paymentData);

View File

@@ -14,7 +14,10 @@ namespace BTCPayServer.Services.Altcoins.Monero.Payments
public override string ToPrettyString() => "On-Chain";
public override string GetId() => "MoneroLike";
public override string ToStringNormalized()
{
return "Monero";
}
public override CryptoPaymentData DeserializePaymentData(BTCPayNetworkBase network, string str)
{

View File

@@ -143,7 +143,7 @@ namespace BTCPayServer.Services.Altcoins.Monero.Services
}
_eventAggregator.Publish(
new InvoiceEvent(invoice, 1002, InvoiceEvent.ReceivedPayment) { Payment = payment });
new InvoiceEvent(invoice, InvoiceEvent.ReceivedPayment) { Payment = payment });
}
private async Task UpdatePaymentStates(string cryptoCode, InvoiceEntity[] invoices)

View File

@@ -4,6 +4,7 @@ using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Payments;
@@ -20,6 +21,7 @@ using NUglify.Helpers;
using YamlDotNet.RepresentationModel;
using YamlDotNet.Serialization;
using static BTCPayServer.Models.AppViewModels.ViewCrowdfundViewModel;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Services.Apps
{
@@ -95,8 +97,8 @@ namespace BTCPayServer.Services.Apps
var currentPayments = GetContributionsByPaymentMethodId(settings.TargetCurrency, completeInvoices, !settings.EnforceTargetAmount);
var perkCount = paidInvoices
.Where(entity => !string.IsNullOrEmpty(entity.ProductInformation.ItemCode))
.GroupBy(entity => entity.ProductInformation.ItemCode)
.Where(entity => !string.IsNullOrEmpty(entity.Metadata.ItemCode))
.GroupBy(entity => entity.Metadata.ItemCode)
.ToDictionary(entities => entities.Key, entities => entities.Count());
var perks = Parse(settings.PerksTemplate, settings.TargetCurrency);
@@ -329,12 +331,12 @@ namespace BTCPayServer.Services.Apps
public Contributions GetContributionsByPaymentMethodId(string currency, InvoiceEntity[] invoices, bool softcap)
{
var contributions = invoices
.Where(p => p.ProductInformation.Currency.Equals(currency, StringComparison.OrdinalIgnoreCase))
.Where(p => p.Currency.Equals(currency, StringComparison.OrdinalIgnoreCase))
.SelectMany(p =>
{
var contribution = new Contribution();
contribution.PaymentMethodId = new PaymentMethodId(p.ProductInformation.Currency, PaymentTypes.BTCLike);
contribution.CurrencyValue = p.ProductInformation.Price;
contribution.PaymentMethodId = new PaymentMethodId(p.Currency, PaymentTypes.BTCLike);
contribution.CurrencyValue = p.Price;
contribution.Value = contribution.CurrencyValue;
// For hardcap, we count newly created invoices as part of the contributions

View File

@@ -56,9 +56,8 @@ namespace BTCPayServer.Services.Invoices.Export
private IEnumerable<ExportInvoiceHolder> convertFromDb(InvoiceEntity invoice)
{
var exportList = new List<ExportInvoiceHolder>();
var currency = Currencies.GetNumberFormatInfo(invoice.ProductInformation.Currency, true);
var invoiceDue = invoice.ProductInformation.Price;
var currency = Currencies.GetNumberFormatInfo(invoice.Currency, true);
var invoiceDue = invoice.Price;
// in this first version we are only exporting invoices that were paid
foreach (var payment in invoice.GetPayments())
{
@@ -88,7 +87,7 @@ namespace BTCPayServer.Services.Invoices.Export
// while looking just at export you could sum Paid and assume merchant "received payments"
NetworkFee = payment.NetworkFee.ToString(CultureInfo.InvariantCulture),
InvoiceDue = Math.Round(invoiceDue, currency.NumberDecimalDigits),
OrderId = invoice.OrderId,
OrderId = invoice.Metadata.OrderId ?? string.Empty,
StoreId = invoice.StoreId,
InvoiceId = invoice.Id,
InvoiceCreatedDate = invoice.InvoiceTime.UtcDateTime,
@@ -99,11 +98,11 @@ namespace BTCPayServer.Services.Invoices.Export
InvoiceStatus = invoice.StatusString,
InvoiceExceptionStatus = invoice.ExceptionStatusString,
#pragma warning restore CS0618 // Type or member is obsolete
InvoiceItemCode = invoice.ProductInformation.ItemCode,
InvoiceItemDesc = invoice.ProductInformation.ItemDesc,
InvoicePrice = invoice.ProductInformation.Price,
InvoiceCurrency = invoice.ProductInformation.Currency,
BuyerEmail = invoice.BuyerInformation?.BuyerEmail
InvoiceItemCode = invoice.Metadata.ItemCode,
InvoiceItemDesc = invoice.Metadata.ItemDesc,
InvoicePrice = invoice.Price,
InvoiceCurrency = invoice.Currency,
BuyerEmail = invoice.Metadata.BuyerEmail
};
exportList.Add(target);

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using Amazon.Runtime.Internal.Util;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.JsonConverters;
@@ -9,17 +10,31 @@ using BTCPayServer.Models;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.CodeAnalysis;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitpayClient;
using NBXplorer;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using YamlDotNet.Core.Tokens;
using YamlDotNet.Serialization.NamingConventions;
namespace BTCPayServer.Services.Invoices
{
public class BuyerInformation
public class InvoiceMetadata
{
public static readonly JsonSerializer MetadataSerializer;
static InvoiceMetadata()
{
var seria = new JsonSerializer();
seria.DefaultValueHandling = DefaultValueHandling.Ignore;
seria.FloatParseHandling = FloatParseHandling.Decimal;
seria.ContractResolver = new CamelCasePropertyNamesContractResolver();
MetadataSerializer = seria;
}
public string OrderId { get; set; }
[JsonProperty(PropertyName = "buyerName")]
public string BuyerName
{
@@ -66,10 +81,7 @@ namespace BTCPayServer.Services.Invoices
{
get; set;
}
}
public class ProductInformation
{
[JsonProperty(PropertyName = "itemDesc")]
public string ItemDesc
{
@@ -81,35 +93,122 @@ namespace BTCPayServer.Services.Invoices
get; set;
}
[JsonProperty(PropertyName = "physical")]
public bool Physical
{
get; set;
}
[JsonProperty(PropertyName = "price")]
public decimal Price
public bool? Physical
{
get; set;
}
[JsonProperty(PropertyName = "taxIncluded", DefaultValueHandling = DefaultValueHandling.Ignore)]
public decimal TaxIncluded
public decimal? TaxIncluded
{
get; set;
}
public string PosData { get; set; }
[JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; }
[JsonProperty(PropertyName = "currency")]
public string Currency
public static InvoiceMetadata FromJObject(JObject jObject)
{
get; set;
return jObject.ToObject<InvoiceMetadata>(MetadataSerializer);
}
public JObject ToJObject()
{
return JObject.FromObject(this, MetadataSerializer);
}
}
public class InvoiceEntity
{
class BuyerInformation
{
[JsonProperty(PropertyName = "buyerName")]
public string BuyerName
{
get; set;
}
[JsonProperty(PropertyName = "buyerEmail")]
public string BuyerEmail
{
get; set;
}
[JsonProperty(PropertyName = "buyerCountry")]
public string BuyerCountry
{
get; set;
}
[JsonProperty(PropertyName = "buyerZip")]
public string BuyerZip
{
get; set;
}
[JsonProperty(PropertyName = "buyerState")]
public string BuyerState
{
get; set;
}
[JsonProperty(PropertyName = "buyerCity")]
public string BuyerCity
{
get; set;
}
[JsonProperty(PropertyName = "buyerAddress2")]
public string BuyerAddress2
{
get; set;
}
[JsonProperty(PropertyName = "buyerAddress1")]
public string BuyerAddress1
{
get; set;
}
[JsonProperty(PropertyName = "buyerPhone")]
public string BuyerPhone
{
get; set;
}
}
class ProductInformation
{
[JsonProperty(PropertyName = "itemDesc")]
public string ItemDesc
{
get; set;
}
[JsonProperty(PropertyName = "itemCode")]
public string ItemCode
{
get; set;
}
[JsonProperty(PropertyName = "physical")]
public bool Physical
{
get; set;
}
[JsonProperty(PropertyName = "price")]
public decimal Price
{
get; set;
}
[JsonProperty(PropertyName = "taxIncluded", DefaultValueHandling = DefaultValueHandling.Ignore)]
public decimal TaxIncluded
{
get; set;
}
[JsonProperty(PropertyName = "currency")]
public string Currency
{
get; set;
}
}
[JsonIgnore]
public BTCPayNetworkProvider Networks { get; set; }
public const int InternalTagSupport_Version = 1;
public const int Lastest_Version = 1;
public const int GreenfieldInvoices_Version = 2;
public const int Lastest_Version = 2;
public int Version { get; set; }
public string Id
{
@@ -120,11 +219,6 @@ namespace BTCPayServer.Services.Invoices
get; set;
}
public string OrderId
{
get; set;
}
public SpeedPolicy SpeedPolicy
{
get; set;
@@ -148,20 +242,20 @@ namespace BTCPayServer.Services.Invoices
{
get; set;
}
public ProductInformation ProductInformation
{
get; set;
}
public BuyerInformation BuyerInformation
{
get; set;
}
public string PosData
public InvoiceMetadata Metadata
{
get;
set;
}
public decimal Price { get; set; }
public string Currency { get; set; }
[JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; }
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public HashSet<string> InternalTags { get; set; } = new HashSet<string>();
@@ -300,7 +394,7 @@ namespace BTCPayServer.Services.Invoices
private Uri FillPlaceholdersUri(string v)
{
var uriStr = (v ?? string.Empty).Replace("{OrderId}", System.Web.HttpUtility.UrlEncode(OrderId) ?? "", StringComparison.OrdinalIgnoreCase)
var uriStr = (v ?? string.Empty).Replace("{OrderId}", System.Web.HttpUtility.UrlEncode(Metadata.OrderId) ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{InvoiceId}", System.Web.HttpUtility.UrlEncode(Id) ?? "", StringComparison.OrdinalIgnoreCase);
if (Uri.TryCreate(uriStr, UriKind.Absolute, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https"))
return uri;
@@ -383,8 +477,8 @@ namespace BTCPayServer.Services.Invoices
{
Id = Id,
StoreId = StoreId,
OrderId = OrderId,
PosData = PosData,
OrderId = Metadata.OrderId,
PosData = Metadata.PosData,
CurrentTime = DateTimeOffset.UtcNow,
InvoiceTime = InvoiceTime,
ExpirationTime = ExpirationTime,
@@ -392,7 +486,7 @@ namespace BTCPayServer.Services.Invoices
Status = StatusString,
ExceptionStatus = ExceptionStatus == InvoiceExceptionStatus.None ? new JValue(false) : new JValue(ExceptionStatusString),
#pragma warning restore CS0618 // Type or member is obsolete
Currency = ProductInformation.Currency,
Currency = Currency,
Flags = new Flags() { Refundable = Refundable },
PaymentSubtotals = new Dictionary<string, decimal>(),
PaymentTotals = new Dictionary<string, decimal>(),
@@ -415,7 +509,7 @@ namespace BTCPayServer.Services.Invoices
var address = details?.GetPaymentDestination();
var exrates = new Dictionary<string, decimal>
{
{ ProductInformation.Currency, cryptoInfo.Rate }
{ Currency, cryptoInfo.Rate }
};
cryptoInfo.CryptoCode = cryptoCode;
@@ -465,7 +559,7 @@ namespace BTCPayServer.Services.Invoices
{
var minerInfo = new MinerFeeInfo();
minerInfo.TotalFee = accounting.NetworkFee.Satoshi;
minerInfo.SatoshiPerBytes = ((BitcoinLikeOnChainPaymentMethod)details).FeeRate
minerInfo.SatoshiPerBytes = ((BitcoinLikeOnChainPaymentMethod) details).FeeRate
.GetFee(1).Satoshi;
dto.MinerFees.TryAdd(cryptoInfo.CryptoCode, minerInfo);
cryptoInfo.PaymentUrls = new InvoicePaymentUrls()
@@ -502,29 +596,27 @@ namespace BTCPayServer.Services.Invoices
//dto.AmountPaid dto.MinerFees & dto.TransactionCurrency are not supported by btcpayserver as we have multi currency payment support per invoice
Populate(ProductInformation, dto);
dto.ItemCode = Metadata.ItemCode;
dto.ItemDesc = Metadata.ItemDesc;
dto.TaxIncluded = Metadata.TaxIncluded ?? 0m;
dto.Price = Price;
dto.Currency = Currency;
dto.Buyer = new JObject();
dto.Buyer.Add(new JProperty("name", BuyerInformation.BuyerName));
dto.Buyer.Add(new JProperty("address1", BuyerInformation.BuyerAddress1));
dto.Buyer.Add(new JProperty("address2", BuyerInformation.BuyerAddress2));
dto.Buyer.Add(new JProperty("locality", BuyerInformation.BuyerCity));
dto.Buyer.Add(new JProperty("region", BuyerInformation.BuyerState));
dto.Buyer.Add(new JProperty("postalCode", BuyerInformation.BuyerZip));
dto.Buyer.Add(new JProperty("country", BuyerInformation.BuyerCountry));
dto.Buyer.Add(new JProperty("phone", BuyerInformation.BuyerPhone));
dto.Buyer.Add(new JProperty("email", string.IsNullOrWhiteSpace(BuyerInformation.BuyerEmail) ? RefundMail : BuyerInformation.BuyerEmail));
dto.Buyer.Add(new JProperty("name", Metadata.BuyerName));
dto.Buyer.Add(new JProperty("address1", Metadata.BuyerAddress1));
dto.Buyer.Add(new JProperty("address2", Metadata.BuyerAddress2));
dto.Buyer.Add(new JProperty("locality", Metadata.BuyerCity));
dto.Buyer.Add(new JProperty("region", Metadata.BuyerState));
dto.Buyer.Add(new JProperty("postalCode", Metadata.BuyerZip));
dto.Buyer.Add(new JProperty("country", Metadata.BuyerCountry));
dto.Buyer.Add(new JProperty("phone", Metadata.BuyerPhone));
dto.Buyer.Add(new JProperty("email", string.IsNullOrWhiteSpace(Metadata.BuyerEmail) ? RefundMail : Metadata.BuyerEmail));
dto.Token = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)); //No idea what it is useful for
dto.Guid = Guid.NewGuid().ToString();
return dto;
}
private void Populate<TFrom, TDest>(TFrom from, TDest dest)
{
var str = JsonConvert.SerializeObject(from);
JsonConvert.PopulateObject(str, dest);
}
internal bool Support(PaymentMethodId paymentMethodId)
{
var rates = GetPaymentMethods();
@@ -602,26 +694,65 @@ namespace BTCPayServer.Services.Invoices
{
return new InvoiceState(Status, ExceptionStatus);
}
/// <summary>
/// Invoice version < 1 were saving metadata directly under the InvoiceEntity
/// object. But in version > 2, the metadata is saved under the InvoiceEntity.Metadata object
/// This method is extracting metadata from the InvoiceEntity of version < 1 invoices and put them in InvoiceEntity.Metadata.
/// </summary>
internal void MigrateLegacyInvoice()
{
T TryParseMetadata<T>(string field) where T : class
{
if (AdditionalData.TryGetValue(field, out var token) && token is JObject obj)
{
return obj.ToObject<T>();
}
return null;
}
if (TryParseMetadata<BuyerInformation>("buyerInformation") is BuyerInformation buyerInformation &&
TryParseMetadata<ProductInformation>("productInformation") is ProductInformation productInformation)
{
var wellknown = new InvoiceMetadata()
{
BuyerAddress1 = buyerInformation.BuyerAddress1,
BuyerAddress2 = buyerInformation.BuyerAddress2,
BuyerCity = buyerInformation.BuyerCity,
BuyerCountry = buyerInformation.BuyerCountry,
BuyerEmail = buyerInformation.BuyerEmail,
BuyerName = buyerInformation.BuyerName,
BuyerPhone = buyerInformation.BuyerPhone,
BuyerState = buyerInformation.BuyerState,
BuyerZip = buyerInformation.BuyerZip,
ItemCode = productInformation.ItemCode,
ItemDesc = productInformation.ItemDesc,
Physical = productInformation.Physical,
TaxIncluded = productInformation.TaxIncluded
};
if (AdditionalData.TryGetValue("posData", out var token) &&
token is JValue val &&
val.Type == JTokenType.String)
{
wellknown.PosData = val.Value<string>();
}
if (AdditionalData.TryGetValue("orderId", out var token2) &&
token2 is JValue val2 &&
val2.Type == JTokenType.String)
{
wellknown.OrderId = val2.Value<string>();
}
Metadata = wellknown;
Currency = productInformation.Currency;
Price = productInformation.Price;
}
else
{
throw new InvalidOperationException("Not a legacy invoice");
}
}
}
public enum InvoiceStatus
{
New,
Paid,
Expired,
Invalid,
Complete,
Confirmed
}
public enum InvoiceExceptionStatus
{
None,
PaidLate,
PaidPartial,
Marked,
Invalid,
PaidOver
}
public class InvoiceState
{
static readonly Dictionary<string, InvoiceStatus> _StringToInvoiceStatus;
@@ -853,7 +984,7 @@ namespace BTCPayServer.Services.Invoices
paymentPredicate = paymentPredicate ?? new Func<PaymentEntity, bool>((p) => true);
var paymentMethods = ParentEntity.GetPaymentMethods();
var totalDue = ParentEntity.ProductInformation.Price / Rate;
var totalDue = ParentEntity.Price / Rate;
var paid = 0m;
var cryptoPaid = 0.0m;

View File

@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Logging;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Payments;
@@ -13,7 +14,9 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Encoders = NBitcoin.DataEncoders.Encoders;
using InvoiceData = BTCPayServer.Data.InvoiceData;
namespace BTCPayServer.Services.Invoices
{
@@ -36,11 +39,12 @@ namespace BTCPayServer.Services.Invoices
}
private readonly ApplicationDbContextFactory _ContextFactory;
private readonly EventAggregator _eventAggregator;
private readonly BTCPayNetworkProvider _Networks;
private readonly CustomThreadPool _IndexerThread;
public InvoiceRepository(ApplicationDbContextFactory contextFactory, string dbreezePath,
BTCPayNetworkProvider networks)
BTCPayNetworkProvider networks, EventAggregator eventAggregator)
{
int retryCount = 0;
retry:
@@ -52,6 +56,7 @@ retry:
_IndexerThread = new CustomThreadPool(1, "Invoice Indexer");
_ContextFactory = contextFactory;
_Networks = networks;
_eventAggregator = eventAggregator;
}
public InvoiceEntity CreateNewInvoice()
@@ -61,6 +66,7 @@ retry:
Networks = _Networks,
Version = InvoiceEntity.Lastest_Version,
InvoiceTime = DateTimeOffset.UtcNow,
Metadata = new InvoiceMetadata()
};
}
@@ -158,11 +164,11 @@ retry:
Id = invoice.Id,
Created = invoice.InvoiceTime,
Blob = ToBytes(invoice, null),
OrderId = invoice.OrderId,
OrderId = invoice.Metadata.OrderId,
#pragma warning disable CS0618 // Type or member is obsolete
Status = invoice.StatusString,
#pragma warning restore CS0618 // Type or member is obsolete
ItemCode = invoice.ProductInformation.ItemCode,
ItemCode = invoice.Metadata.ItemCode,
CustomerEmail = invoice.RefundMail,
Archived = false
});
@@ -194,10 +200,9 @@ retry:
textSearch.Add(invoice.Id);
textSearch.Add(invoice.InvoiceTime.ToString(CultureInfo.InvariantCulture));
textSearch.Add(invoice.ProductInformation.Price.ToString(CultureInfo.InvariantCulture));
textSearch.Add(invoice.OrderId);
textSearch.Add(ToString(invoice.BuyerInformation, null));
textSearch.Add(ToString(invoice.ProductInformation, null));
textSearch.Add(invoice.Price.ToString(CultureInfo.InvariantCulture));
textSearch.Add(invoice.Metadata.OrderId);
textSearch.Add(ToString(invoice.Metadata, null));
textSearch.Add(invoice.StoreId);
AddToTextSearch(invoice.Id, textSearch.ToArray());
@@ -427,42 +432,68 @@ retry:
}
}
public async Task ToggleInvoiceArchival(string invoiceId, bool archived)
public async Task ToggleInvoiceArchival(string invoiceId, bool archived, string storeId = null)
{
using (var context = _ContextFactory.CreateContext())
{
var invoiceData = await context.FindAsync<InvoiceData>(invoiceId).ConfigureAwait(false);
if (invoiceData == null || invoiceData.Archived == archived)
if (invoiceData == null || invoiceData.Archived == archived ||
(storeId != null &&
!invoiceData.StoreDataId.Equals(storeId, StringComparison.InvariantCultureIgnoreCase)))
return;
invoiceData.Archived = archived;
await context.SaveChangesAsync().ConfigureAwait(false);
}
}
public async Task UpdatePaidInvoiceToInvalid(string invoiceId)
public async Task<bool> MarkInvoiceStatus(string invoiceId, InvoiceStatus status)
{
using (var context = _ContextFactory.CreateContext())
{
var invoiceData = await context.FindAsync<Data.InvoiceData>(invoiceId).ConfigureAwait(false);
if (invoiceData == null || !invoiceData.GetInvoiceState().CanMarkInvalid())
return;
invoiceData.Status = "invalid";
invoiceData.ExceptionStatus = "marked";
await context.SaveChangesAsync().ConfigureAwait(false);
}
}
public async Task UpdatePaidInvoiceToComplete(string invoiceId)
{
using (var context = _ContextFactory.CreateContext())
{
var invoiceData = await context.FindAsync<Data.InvoiceData>(invoiceId).ConfigureAwait(false);
if (invoiceData == null || !invoiceData.GetInvoiceState().CanMarkComplete())
return;
invoiceData.Status = "complete";
invoiceData.ExceptionStatus = "marked";
await context.SaveChangesAsync().ConfigureAwait(false);
var invoiceData = await GetInvoiceRaw(invoiceId);
if (invoiceData == null)
{
return false;
}
context.Attach(invoiceData);
string eventName;
switch (status)
{
case InvoiceStatus.Complete:
if (!invoiceData.GetInvoiceState().CanMarkComplete())
{
return false;
}
eventName = InvoiceEvent.MarkedCompleted;
break;
case InvoiceStatus.Invalid:
if (!invoiceData.GetInvoiceState().CanMarkInvalid())
{
return false;
}
eventName = InvoiceEvent.MarkedInvalid;
break;
default:
return false;
}
invoiceData.Status = status.ToString().ToLowerInvariant();
invoiceData.ExceptionStatus = InvoiceExceptionStatus.Marked.ToString().ToLowerInvariant();
_eventAggregator.Publish(new InvoiceEvent(ToEntity(invoiceData), eventName));
await context.SaveChangesAsync();
}
return true;
}
public async Task<InvoiceEntity> GetInvoice(string id, bool inludeAddressData = false)
{
var res = await GetInvoiceRaw(id, inludeAddressData);
return res == null ? null : ToEntity(res);
}
private async Task<InvoiceData> GetInvoiceRaw(string id, bool inludeAddressData = false)
{
using (var context = _ContextFactory.CreateContext())
{
@@ -478,7 +509,7 @@ retry:
if (invoice == null)
return null;
return ToEntity(invoice);
return invoice;
}
}
@@ -525,10 +556,9 @@ retry:
{
entity.Events = invoice.Events.OrderBy(c => c.Timestamp).ToList();
}
if (!string.IsNullOrEmpty(entity.RefundMail) && string.IsNullOrEmpty(entity.BuyerInformation.BuyerEmail))
if (!string.IsNullOrEmpty(entity.RefundMail) && string.IsNullOrEmpty(entity.Metadata.BuyerEmail))
{
entity.BuyerInformation.BuyerEmail = entity.RefundMail;
entity.Metadata.BuyerEmail = entity.RefundMail;
}
entity.Archived = invoice.Archived;
return entity;

View File

@@ -1,4 +1,4 @@
@model InvoiceDetailsModel
@model InvoiceDetailsModel
@{
ViewData["Title"] = "Invoice " + Model.Id;
}
@@ -93,7 +93,7 @@
</tr>
<tr>
<th>Order Id</th>
<td>@Model.OrderId</td>
<td>@Model.TypedMetadata.OrderId</td>
</tr>
<tr>
<th>Total fiat due</th>
@@ -114,39 +114,39 @@
<table class="table table-sm table-responsive-md removetopborder">
<tr>
<th>Name</th>
<td>@Model.BuyerInformation.BuyerName</td>
<td>@Model.TypedMetadata.BuyerName</td>
</tr>
<tr>
<th>Email</th>
<td><a href="mailto:@Model.BuyerInformation.BuyerEmail">@Model.BuyerInformation.BuyerEmail</a></td>
<td><a href="mailto:@Model.TypedMetadata.BuyerEmail">@Model.TypedMetadata.BuyerEmail</a></td>
</tr>
<tr>
<th>Phone</th>
<td>@Model.BuyerInformation.BuyerPhone</td>
<td>@Model.TypedMetadata.BuyerPhone</td>
</tr>
<tr>
<th>Address 1</th>
<td>@Model.BuyerInformation.BuyerAddress1</td>
<td>@Model.TypedMetadata.BuyerAddress1</td>
</tr>
<tr>
<th>Address 2</th>
<td>@Model.BuyerInformation.BuyerAddress2</td>
<td>@Model.TypedMetadata.BuyerAddress2</td>
</tr>
<tr>
<th>City</th>
<td>@Model.BuyerInformation.BuyerCity</td>
<td>@Model.TypedMetadata.BuyerCity</td>
</tr>
<tr>
<th>State</th>
<td>@Model.BuyerInformation.BuyerState</td>
<td>@Model.TypedMetadata.BuyerState</td>
</tr>
<tr>
<th>Country</th>
<td>@Model.BuyerInformation.BuyerCountry</td>
<td>@Model.TypedMetadata.BuyerCountry</td>
</tr>
<tr>
<th>Zip</th>
<td>@Model.BuyerInformation.BuyerZip</td>
<td>@Model.TypedMetadata.BuyerZip</td>
</tr>
</table>
@if (Model.PosData.Count == 0)
@@ -155,11 +155,11 @@
<table class="table table-sm table-responsive-md removetopborder">
<tr>
<th>Item code</th>
<td>@Model.ProductInformation.ItemCode</td>
<td>@Model.TypedMetadata.ItemCode</td>
</tr>
<tr>
<th>Item Description</th>
<td>@Model.ProductInformation.ItemDesc</td>
<td>@Model.TypedMetadata.ItemDesc</td>
</tr>
<tr>
<th>Price</th>
@@ -182,11 +182,11 @@
<table class="table table-sm table-responsive-md removetopborder">
<tr>
<th>Item code</th>
<td>@Model.ProductInformation.ItemCode</td>
<td>@Model.TypedMetadata.ItemCode</td>
</tr>
<tr>
<th>Item Description</th>
<td>@Model.ProductInformation.ItemDesc</td>
<td>@Model.TypedMetadata.ItemDesc</td>
</tr>
<tr>
<th>Price</th>

View File

@@ -1,4 +1,5 @@
@model (InvoiceDetailsModel Invoice, bool ShowAddress)
@using BTCPayServer.Client.Models
@model (InvoiceDetailsModel Invoice, bool ShowAddress)
@{ var invoice = Model.Invoice; }
@inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary

View File

@@ -0,0 +1,576 @@
{
"paths": {
"/api/v1/stores/{storeId}/invoices": {
"get": {
"tags": [
"Invoices"
],
"summary": "Get invoices",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to query",
"schema": {
"type": "string"
}
}
],
"description": "View information about the existing invoices",
"operationId": "Invoices_GetInvoices",
"responses": {
"200": {
"description": "list of invoices",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvoiceDataList"
}
}
}
}
},
"security": [
{
"API Key": [
"btcpay.store.canviewinvoices"
],
"Basic": []
}
]
},
"post": {
"tags": [
"Invoices"
],
"summary": "Create a new invoice",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to query",
"schema": {
"type": "string"
}
}
],
"description": "Create a new invoice",
"operationId": "Invoices_CreateInvoice",
"responses": {
"200": {
"description": "Information about the new invoice",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvoiceData"
}
}
}
},
"400": {
"description": "A list of errors that occurred when creating the invoice",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to add new invoices"
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateInvoiceRequest"
}
}
}
},
"security": [
{
"API Key": [
"btcpay.store.cancreateinvoice"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/invoices/{invoiceId}": {
"get": {
"tags": [
"Invoices"
],
"summary": "Get invoice",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to fetch",
"schema": {
"type": "string"
}
},
{
"name": "invoiceId",
"in": "path",
"required": true,
"description": "The invoice to fetch",
"schema": {
"type": "string"
}
}
],
"description": "View information about the specified invoice",
"operationId": "Invoices_GetInvoice",
"responses": {
"200": {
"description": "specified invoice",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvoiceData"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to view the specified invoie"
},
"404": {
"description": "The key is not found for this invoice"
}
},
"security": [
{
"API Key": [
"btcpay.store.canviewinvoices"
],
"Basic": []
}
]
},
"delete": {
"tags": [
"Invoices"
],
"summary": "Archive invoice",
"description": "Archives the specified invoice.",
"operationId": "Invoices_ArchiveInvoice",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store the invoice belongs to",
"schema": {
"type": "string"
}
},
{
"name": "invoiceId",
"in": "path",
"required": true,
"description": "The invoice to remove",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The invoice has been archived"
},
"400": {
"description": "A list of errors that occurred when archiving the invoice",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to archive the specified invoice"
},
"404": {
"description": "The key is not found for this invoice"
}
},
"security": [
{
"API Key": [
"btcpay.store.canmodifystoresettings"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/invoices/{invoiceId}/status": {
"post": {
"tags": [
"Invoices"
],
"summary": "Mark invoice status",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to query",
"schema": {
"type": "string"
}
},
{
"name": "invoiceId",
"in": "path",
"required": true,
"description": "The invoice to update",
"schema": {
"type": "string"
}
}
],
"description": "Mark an invoice as invalid or completed.",
"operationId": "Invoices_MarkInvoiceStatus",
"responses": {
"200": {
"description": "The updated invoice",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvoiceData"
}
}
}
},
"400": {
"description": "A list of errors that occurred when updating the invoice",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to update the invoice"
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MarkInvoiceStatusRequest"
}
}
}
},
"security": [
{
"API Key": [
"btcpay.store.canmodifystoresettings"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/invoices/{invoiceId}/unarchive": {
"post": {
"tags": [
"Invoices"
],
"summary": "Unarchive invoice",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to query",
"schema": {
"type": "string"
}
},
{
"name": "invoiceId",
"in": "path",
"required": true,
"description": "The invoice to update",
"schema": {
"type": "string"
}
}
],
"description": "Unarchive an invoice",
"operationId": "Invoices_UnarchiveInvoice",
"responses": {
"200": {
"description": "The unarchived invoice",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvoiceData"
}
}
}
},
"400": {
"description": "A list of errors that occurred when updating the invoice",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to update the invoice"
}
},
"security": [
{
"API Key": [
"btcpay.store.canmodifystoresettings"
],
"Basic": []
}
]
}
}
},
"components": {
"schemas": {
"InvoiceDataList": {
"type": "array",
"items": {
"$ref": "#/components/schemas/InvoiceData"
}
},
"MarkInvoiceStatusRequest": {
"type": "object",
"additionalProperties": false,
"properties": {
"status": {
"nullable": false,
"description": "Mark an invoice as completed or invalid.",
"oneOf": [
{
"$ref": "#/components/schemas/InvoiceStatusMark"
}
]
}
}
},
"AddCustomerEmailRequest": {
"type": "object",
"additionalProperties": false,
"properties": {
"email": {
"type": "string",
"nullable": false,
"description": "Sets the customer email, if it was not set before."
}
}
},
"InvoiceStatusMark": {
"type": "string",
"description": "",
"x-enumNames": [
"Invalid",
"Complete"
],
"enum": [
"Invalid",
"Complete"
]
},
"InvoiceStatus": {
"type": "string",
"description": "",
"x-enumNames": [
"New",
"Paid",
"Expired",
"Invalid",
"Complete",
"Confirmed"
],
"enum": [
"New",
"Paid",
"Expired",
"Invalid",
"Complete",
"Confirmed"
]
},
"InvoiceAdditionalStatus": {
"type": "string",
"description": "An additional status that describes why an invoice is in its current status.",
"x-enumNames": [
"None",
"PaidLate",
"PaidPartial",
"Marked",
"Invalid",
"PaidOver"
],
"enum": [
"None",
"PaidLate",
"PaidPartial",
"Marked",
"Invalid",
"PaidOver"
]
},
"InvoiceData": {
"allOf": [
{
"$ref": "#/components/schemas/CreateInvoiceRequest"
},
{
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "The identifier of the invoice"
},
"createdTime": {
"type": "number",
"format": "int64",
"description": "The creation time of the invoice"
},
"expirationTime": {
"type": "number",
"format": "int64",
"description": "The expiration time of the invoice"
},
"monitoringTime": {
"type": "number",
"format": "int64",
"description": "The monitoring time of the invoice"
},
"status": {
"$ref": "#/components/schemas/InvoiceStatus",
"description": "The status of the invoice"
},
"additionalStatus": {
"$ref": "#/components/schemas/InvoiceAdditionalStatus",
"description": "a secondary status of the invoice"
}
}
}
]
},
"CreateInvoiceRequest": {
"type": "object",
"additionalProperties": false,
"properties": {
"amount": {
"type": "string",
"format": "decimal",
"description": "The amount of the invoice"
},
"currency": {
"type": "string",
"nullable": true,
"description": "The currency the invoice will use"
},
"metadata": {
"type": "string",
"nullable": true,
"description": "Additional information around the invoice that can be supplied."
},
"checkout": {
"nullable": true,
"oneOf": [
{
"$ref": "#/components/schemas/CheckoutOptions"
}
],
"description": "Additional settings to customize the checkout flow"
}
}
},
"CheckoutOptions": {
"type": "object",
"additionalProperties": false,
"properties": {
"speedPolicy": {
"nullable": true,
"oneOf": [
{
"$ref": "#/components/schemas/SpeedPolicy"
}
],
"description": "This is a risk mitigation parameter for the merchant to configure how they want to fulfill orders depending on the number of block confirmations for the transaction made by the consumer on the selected cryptocurrency"
},
"paymentMethods": {
"type": "array",
"nullable": true,
"items": {
"type": "string"
},
"description": "A specific set of payment methods to use for this invoice (ie. BTC, BTC-LightningNetwork). By default, select all payment methods activated in the store."
},
"expirationMinutes": {
"type": "integer",
"nullable": true,
"description": "The number of minutes after which an invoice becomes expired. Default to the store's settings. (The default store settings is 15)"
},
"monitoringMinutes": {
"type": "integer",
"nullable": true,
"description": "The number of minutes after an invoice expired after which we are still monitoring for incoming payments. Default to the store's settings. (The default store settings is 1440, 1 day)"
},
"paymentTolerance": {
"type": "number",
"format": "double",
"nullable": true,
"minimum": 0,
"maximum": 100,
"description": "A percentage determining whether to count the invoice as paid when the invoice is paid within the specified margin of error. Default to the store's settings. (The default store settings is 100)"
}
}
},
"SpeedPolicy": {
"type": "string",
"description": "",
"x-enumNames": [
"HighSpeed",
"MediumSpeed",
"LowSpeed",
"LowMediumSpeed"
],
"enum": [
"HighSpeed",
"MediumSpeed",
"LowSpeed",
"LowMediumSpeed"
]
}
}
},
"tags": [
{
"name": "Invoices"
}
]
}

View File

@@ -122,7 +122,7 @@
}
],
"description": "View information about the specified payment request",
"operationId": "PaymentRequests_GetPaymentRequests",
"operationId": "PaymentRequests_GetPaymentRequest",
"responses": {
"200": {
"description": "specified payment request",
@@ -218,7 +218,7 @@
"name": "paymentRequestId",
"in": "path",
"required": true,
"description": "The payment request to remove",
"description": "The payment request to update",
"schema": { "type": "string" }
}
],