mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-18 22:44:29 +01:00
Fix: Payments to Top-Up could be undetected due to race condition (#5568)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
@@ -31,7 +32,9 @@ namespace BTCPayServer.Data
|
|||||||
public List<InvoiceSearchData> InvoiceSearchData { get; set; }
|
public List<InvoiceSearchData> InvoiceSearchData { get; set; }
|
||||||
public List<RefundData> Refunds { get; set; }
|
public List<RefundData> Refunds { get; set; }
|
||||||
|
|
||||||
|
[Timestamp]
|
||||||
|
// With this, update of InvoiceData will fail if the row was modified by another process
|
||||||
|
public uint XMin { get; set; }
|
||||||
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||||
{
|
{
|
||||||
builder.Entity<InvoiceData>()
|
builder.Entity<InvoiceData>()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -16,25 +16,7 @@ namespace BTCPayServer.Migrations
|
|||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.9");
|
modelBuilder.HasAnnotation("ProductVersion", "8.0.0");
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Address")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("CreatedTime")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("InvoiceDataId")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.HasKey("Address");
|
|
||||||
|
|
||||||
b.HasIndex("InvoiceDataId");
|
|
||||||
|
|
||||||
b.ToTable("AddressInvoices");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b =>
|
modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b =>
|
||||||
{
|
{
|
||||||
@@ -71,6 +53,24 @@ namespace BTCPayServer.Migrations
|
|||||||
b.ToTable("ApiKeys");
|
b.ToTable("ApiKeys");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Address")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("CreatedTime")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("InvoiceDataId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Address");
|
||||||
|
|
||||||
|
b.HasIndex("InvoiceDataId");
|
||||||
|
|
||||||
|
b.ToTable("AddressInvoices");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.AppData", b =>
|
modelBuilder.Entity("BTCPayServer.Data.AppData", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -89,7 +89,7 @@ namespace BTCPayServer.Migrations
|
|||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Settings")
|
b.Property<string>("Settings")
|
||||||
.HasColumnType("JSONB");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("StoreDataId")
|
b.Property<string>("StoreDataId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -305,6 +305,11 @@ namespace BTCPayServer.Migrations
|
|||||||
b.Property<string>("StoreDataId")
|
b.Property<string>("StoreDataId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<uint>("XMin")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.ValueGeneratedOnAddOrUpdate()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("Created");
|
b.HasIndex("Created");
|
||||||
@@ -781,31 +786,6 @@ namespace BTCPayServer.Migrations
|
|||||||
b.ToTable("Stores");
|
b.ToTable("Stores");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.StoredFile", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("ApplicationUserId")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("FileName")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<string>("StorageFileName")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<DateTime>("Timestamp")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("ApplicationUserId");
|
|
||||||
|
|
||||||
b.ToTable("Files");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.StoreRole", b =>
|
modelBuilder.Entity("BTCPayServer.Data.StoreRole", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -863,6 +843,31 @@ namespace BTCPayServer.Migrations
|
|||||||
b.ToTable("StoreWebhooks");
|
b.ToTable("StoreWebhooks");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.StoredFile", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ApplicationUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("FileName")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StorageFileName")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Timestamp")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ApplicationUserId");
|
||||||
|
|
||||||
|
b.ToTable("Files");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.U2FDevice", b =>
|
modelBuilder.Entity("BTCPayServer.Data.U2FDevice", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -1171,16 +1176,6 @@ namespace BTCPayServer.Migrations
|
|||||||
b.ToTable("AspNetUserTokens", (string)null);
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")
|
|
||||||
.WithMany("AddressInvoices")
|
|
||||||
.HasForeignKey("InvoiceDataId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
b.Navigation("InvoiceData");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b =>
|
modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
|
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
|
||||||
@@ -1198,6 +1193,16 @@ namespace BTCPayServer.Migrations
|
|||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")
|
||||||
|
.WithMany("AddressInvoices")
|
||||||
|
.HasForeignKey("InvoiceDataId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.Navigation("InvoiceData");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.AppData", b =>
|
modelBuilder.Entity("BTCPayServer.Data.AppData", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
|
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
|
||||||
@@ -1408,15 +1413,6 @@ namespace BTCPayServer.Migrations
|
|||||||
b.Navigation("PullPaymentData");
|
b.Navigation("PullPaymentData");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.StoredFile", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
|
|
||||||
.WithMany("StoredFiles")
|
|
||||||
.HasForeignKey("ApplicationUserId");
|
|
||||||
|
|
||||||
b.Navigation("ApplicationUser");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.StoreRole", b =>
|
modelBuilder.Entity("BTCPayServer.Data.StoreRole", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
|
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
|
||||||
@@ -1457,6 +1453,15 @@ namespace BTCPayServer.Migrations
|
|||||||
b.Navigation("Webhook");
|
b.Navigation("Webhook");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.StoredFile", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
|
||||||
|
.WithMany("StoredFiles")
|
||||||
|
.HasForeignKey("ApplicationUserId");
|
||||||
|
|
||||||
|
b.Navigation("ApplicationUser");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.U2FDevice", b =>
|
modelBuilder.Entity("BTCPayServer.Data.U2FDevice", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
|
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
|
||||||
|
|||||||
@@ -105,8 +105,15 @@ namespace BTCPayServer.Tests
|
|||||||
|
|
||||||
public void MineBlockOnInvoiceCheckout()
|
public void MineBlockOnInvoiceCheckout()
|
||||||
{
|
{
|
||||||
Driver.FindElement(By.CssSelector("#mine-block button")).Click();
|
retry:
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Driver.FindElement(By.CssSelector("#mine-block button")).Click();
|
||||||
|
}
|
||||||
|
catch (StaleElementReferenceException)
|
||||||
|
{
|
||||||
|
goto retry;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1149,7 +1149,6 @@ namespace BTCPayServer.Tests
|
|||||||
|
|
||||||
// Contribute
|
// Contribute
|
||||||
s.Driver.FindElement(By.Id("crowdfund-body-header-cta")).Click();
|
s.Driver.FindElement(By.Id("crowdfund-body-header-cta")).Click();
|
||||||
Thread.Sleep(1000);
|
|
||||||
s.Driver.WaitUntilAvailable(By.Name("btcpay"));
|
s.Driver.WaitUntilAvailable(By.Name("btcpay"));
|
||||||
|
|
||||||
var frameElement = s.Driver.FindElement(By.Name("btcpay"));
|
var frameElement = s.Driver.FindElement(By.Name("btcpay"));
|
||||||
|
|||||||
@@ -395,7 +395,7 @@ namespace BTCPayServer.Tests
|
|||||||
BOLT11PaymentRequest.Parse(newBolt11, Network.RegTest).MinimumAmount.ToDecimal(LightMoneyUnit.BTC));
|
BOLT11PaymentRequest.Parse(newBolt11, Network.RegTest).MinimumAmount.ToDecimal(LightMoneyUnit.BTC));
|
||||||
}, 40000);
|
}, 40000);
|
||||||
|
|
||||||
TestLogs.LogInformation($"Paying invoice {newInvoice.Id} remaining due amount {newInvoice.BtcDue.GetValue((BTCPayNetwork) tester.DefaultNetwork)} via lightning");
|
TestLogs.LogInformation($"Paying invoice {newInvoice.Id} remaining due amount {newInvoice.BtcDue.GetValue((BTCPayNetwork)tester.DefaultNetwork)} via lightning");
|
||||||
var evt = await tester.WaitForEvent<InvoiceDataChangedEvent>(async () =>
|
var evt = await tester.WaitForEvent<InvoiceDataChangedEvent>(async () =>
|
||||||
{
|
{
|
||||||
await tester.SendLightningPaymentAsync(newInvoice);
|
await tester.SendLightningPaymentAsync(newInvoice);
|
||||||
@@ -1301,11 +1301,8 @@ namespace BTCPayServer.Tests
|
|||||||
var user = tester.NewAccount();
|
var user = tester.NewAccount();
|
||||||
await user.GrantAccessAsync();
|
await user.GrantAccessAsync();
|
||||||
user.RegisterDerivationScheme("BTC");
|
user.RegisterDerivationScheme("BTC");
|
||||||
|
await tester.ExplorerNode.EnsureGenerateAsync(1);
|
||||||
var rng = new Random();
|
var rng = new Random();
|
||||||
var seed = rng.Next();
|
|
||||||
rng = new Random(seed);
|
|
||||||
TestLogs.LogInformation("Seed: " + seed);
|
|
||||||
foreach (var networkFeeMode in Enum.GetValues(typeof(NetworkFeeMode)).Cast<NetworkFeeMode>())
|
foreach (var networkFeeMode in Enum.GetValues(typeof(NetworkFeeMode)).Cast<NetworkFeeMode>())
|
||||||
{
|
{
|
||||||
await user.SetNetworkFeeMode(networkFeeMode);
|
await user.SetNetworkFeeMode(networkFeeMode);
|
||||||
@@ -1318,7 +1315,7 @@ namespace BTCPayServer.Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task AssertTopUpBtcPrice(ServerTester tester, TestAccount user, Money btcSent, decimal expectedPriceWithoutNetworkFee, NetworkFeeMode networkFeeMode)
|
private async Task AssertTopUpBtcPrice(ServerTester tester, TestAccount user, Money btcSent, decimal expectedPriceWithoutNetworkFee, NetworkFeeMode networkFeeMode)
|
||||||
{
|
{
|
||||||
var cashCow = tester.ExplorerNode;
|
var cashCow = tester.ExplorerNode;
|
||||||
// First we try payment with a merchant having only BTC
|
// First we try payment with a merchant having only BTC
|
||||||
@@ -1343,7 +1340,6 @@ namespace BTCPayServer.Tests
|
|||||||
{
|
{
|
||||||
networkFee = 0.0m;
|
networkFee = 0.0m;
|
||||||
}
|
}
|
||||||
|
|
||||||
await cashCow.SendToAddressAsync(invoiceAddress, paid);
|
await cashCow.SendToAddressAsync(invoiceAddress, paid);
|
||||||
await TestUtils.EventuallyAsync(async () =>
|
await TestUtils.EventuallyAsync(async () =>
|
||||||
{
|
{
|
||||||
@@ -1822,7 +1818,7 @@ namespace BTCPayServer.Tests
|
|||||||
Assert.Empty(appList2.Apps);
|
Assert.Empty(appList2.Apps);
|
||||||
Assert.Equal("test", appList.Apps[0].AppName);
|
Assert.Equal("test", appList.Apps[0].AppName);
|
||||||
Assert.Equal(apps.CreatedAppId, appList.Apps[0].Id);
|
Assert.Equal(apps.CreatedAppId, appList.Apps[0].Id);
|
||||||
|
|
||||||
Assert.True(app.Role.ToPermissionSet(app.StoreId).Contains(Policies.CanModifyStoreSettings, app.StoreId));
|
Assert.True(app.Role.ToPermissionSet(app.StoreId).Contains(Policies.CanModifyStoreSettings, app.StoreId));
|
||||||
Assert.Equal(user.StoreId, appList.Apps[0].StoreId);
|
Assert.Equal(user.StoreId, appList.Apps[0].StoreId);
|
||||||
Assert.IsType<NotFoundResult>(apps2.DeleteApp(appList.Apps[0].Id));
|
Assert.IsType<NotFoundResult>(apps2.DeleteApp(appList.Apps[0].Id));
|
||||||
@@ -2399,11 +2395,11 @@ namespace BTCPayServer.Tests
|
|||||||
|
|
||||||
var url = lnMethod.GetExternalLightningUrl();
|
var url = lnMethod.GetExternalLightningUrl();
|
||||||
var kv = LightningConnectionStringHelper.ExtractValues(url, out var connType);
|
var kv = LightningConnectionStringHelper.ExtractValues(url, out var connType);
|
||||||
Assert.Equal(LightningConnectionType.Charge,connType);
|
Assert.Equal(LightningConnectionType.Charge, connType);
|
||||||
var client = Assert.IsType<ChargeClient>(tester.PayTester.GetService<LightningClientFactoryService>()
|
var client = Assert.IsType<ChargeClient>(tester.PayTester.GetService<LightningClientFactoryService>()
|
||||||
.Create(url, tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC")));
|
.Create(url, tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC")));
|
||||||
var auth = Assert.IsType<ChargeAuthentication.UserPasswordAuthentication>(client.ChargeAuthentication);
|
var auth = Assert.IsType<ChargeAuthentication.UserPasswordAuthentication>(client.ChargeAuthentication);
|
||||||
|
|
||||||
Assert.Equal("pass", auth.NetworkCredential.Password);
|
Assert.Equal("pass", auth.NetworkCredential.Password);
|
||||||
Assert.Equal("usr", auth.NetworkCredential.UserName);
|
Assert.Equal("usr", auth.NetworkCredential.UserName);
|
||||||
|
|
||||||
@@ -2829,7 +2825,7 @@ namespace BTCPayServer.Tests
|
|||||||
var app = await client.CreatePointOfSaleApp(acc.StoreId, new CreatePointOfSaleAppRequest()
|
var app = await client.CreatePointOfSaleApp(acc.StoreId, new CreatePointOfSaleAppRequest()
|
||||||
{
|
{
|
||||||
AppName = "Static",
|
AppName = "Static",
|
||||||
DefaultView = Client.Models.PosViewType.Static,
|
DefaultView = Client.Models.PosViewType.Static,
|
||||||
Template = new PointOfSaleSettings().Template
|
Template = new PointOfSaleSettings().Template
|
||||||
});
|
});
|
||||||
var resp = await posController.ViewPointOfSale(app.Id, choiceKey: "green-tea");
|
var resp = await posController.ViewPointOfSale(app.Id, choiceKey: "green-tea");
|
||||||
@@ -2839,7 +2835,7 @@ namespace BTCPayServer.Tests
|
|||||||
app = await client.CreatePointOfSaleApp(acc.StoreId, new CreatePointOfSaleAppRequest()
|
app = await client.CreatePointOfSaleApp(acc.StoreId, new CreatePointOfSaleAppRequest()
|
||||||
{
|
{
|
||||||
AppName = "Cart",
|
AppName = "Cart",
|
||||||
DefaultView = Client.Models.PosViewType.Cart,
|
DefaultView = Client.Models.PosViewType.Cart,
|
||||||
Template = new PointOfSaleSettings().Template
|
Template = new PointOfSaleSettings().Template
|
||||||
});
|
});
|
||||||
resp = await posController.ViewPointOfSale(app.Id, posData: new JObject()
|
resp = await posController.ViewPointOfSale(app.Id, posData: new JObject()
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ namespace BTCPayServer.Data
|
|||||||
|
|
||||||
public static StoreBlob GetStoreBlob(this StoreData storeData)
|
public static StoreBlob GetStoreBlob(this StoreData storeData)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(storeData);
|
||||||
var result = storeData.StoreBlob == null ? new StoreBlob() : new Serializer(null).ToObject<StoreBlob>(storeData.StoreBlob);
|
var result = storeData.StoreBlob == null ? new StoreBlob() : new Serializer(null).ToObject<StoreBlob>(storeData.StoreBlob);
|
||||||
if (result.PreferredExchange == null)
|
if (result.PreferredExchange == null)
|
||||||
result.PreferredExchange = result.GetRecommendedExchange();
|
result.PreferredExchange = result.GetRecommendedExchange();
|
||||||
|
|||||||
@@ -38,11 +38,10 @@ namespace BTCPayServer.HostedServices
|
|||||||
|
|
||||||
public bool Dirty => _dirty;
|
public bool Dirty => _dirty;
|
||||||
|
|
||||||
bool _isBlobUpdated;
|
public bool IsPriceUpdated { get; private set; }
|
||||||
public bool IsBlobUpdated => _isBlobUpdated;
|
public void PriceUpdated()
|
||||||
public void BlobUpdated()
|
|
||||||
{
|
{
|
||||||
_isBlobUpdated = true;
|
IsPriceUpdated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +103,7 @@ namespace BTCPayServer.HostedServices
|
|||||||
var payment = invoice.GetPayments(true).First();
|
var payment = invoice.GetPayments(true).First();
|
||||||
invoice.Price = payment.InvoicePaidAmount.Net;
|
invoice.Price = payment.InvoicePaidAmount.Net;
|
||||||
invoice.UpdateTotals();
|
invoice.UpdateTotals();
|
||||||
context.BlobUpdated();
|
context.PriceUpdated();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -291,9 +290,9 @@ namespace BTCPayServer.HostedServices
|
|||||||
await _invoiceRepository.UpdateInvoiceStatus(invoice.Id, invoice.GetInvoiceState());
|
await _invoiceRepository.UpdateInvoiceStatus(invoice.Id, invoice.GetInvoiceState());
|
||||||
updateContext.Events.Insert(0, new InvoiceDataChangedEvent(invoice));
|
updateContext.Events.Insert(0, new InvoiceDataChangedEvent(invoice));
|
||||||
}
|
}
|
||||||
if (updateContext.IsBlobUpdated)
|
if (updateContext.IsPriceUpdated)
|
||||||
{
|
{
|
||||||
await _invoiceRepository.UpdateInvoicePrice(invoice.Id, invoice);
|
await _invoiceRepository.UpdateInvoicePrice(invoice.Id, invoice.Price);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var evt in updateContext.Events)
|
foreach (var evt in updateContext.Events)
|
||||||
|
|||||||
@@ -79,13 +79,13 @@ namespace BTCPayServer.Services.Apps
|
|||||||
|
|
||||||
public async Task<object?> GetInfo(string appId)
|
public async Task<object?> GetInfo(string appId)
|
||||||
{
|
{
|
||||||
var appData = await GetApp(appId, null);
|
var appData = await GetApp(appId, null, includeStore: true);
|
||||||
if (appData is null)
|
if (appData is null)
|
||||||
return null;
|
return null;
|
||||||
var appType = GetAppType(appData.AppType);
|
var appType = GetAppType(appData.AppType);
|
||||||
if (appType is null)
|
if (appType is null)
|
||||||
return null;
|
return null;
|
||||||
return appType.GetInfo(appData);
|
return await appType.GetInfo(appData);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<ItemStats>> GetItemStats(AppData appData)
|
public async Task<IEnumerable<ItemStats>> GetItemStats(AppData appData)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using BTCPayServer.Events;
|
|||||||
using BTCPayServer.Logging;
|
using BTCPayServer.Logging;
|
||||||
using BTCPayServer.Models.InvoicingModels;
|
using BTCPayServer.Models.InvoicingModels;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
|
using Dapper;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
@@ -130,32 +131,50 @@ namespace BTCPayServer.Services.Invoices
|
|||||||
|
|
||||||
public async Task UpdateInvoice(string invoiceId, UpdateCustomerModel data)
|
public async Task UpdateInvoice(string invoiceId, UpdateCustomerModel data)
|
||||||
{
|
{
|
||||||
using var ctx = _applicationDbContextFactory.CreateContext();
|
retry:
|
||||||
var invoiceData = await ctx.Invoices.FindAsync(invoiceId).ConfigureAwait(false);
|
using (var ctx = _applicationDbContextFactory.CreateContext())
|
||||||
if (invoiceData == null)
|
|
||||||
return;
|
|
||||||
if (invoiceData.CustomerEmail == null && data.Email != null)
|
|
||||||
{
|
{
|
||||||
invoiceData.CustomerEmail = data.Email;
|
var invoiceData = await ctx.Invoices.FindAsync(invoiceId);
|
||||||
AddToTextSearch(ctx, invoiceData, invoiceData.CustomerEmail);
|
if (invoiceData == null)
|
||||||
|
return;
|
||||||
|
if (invoiceData.CustomerEmail == null && data.Email != null)
|
||||||
|
{
|
||||||
|
invoiceData.CustomerEmail = data.Email;
|
||||||
|
AddToTextSearch(ctx, invoiceData, invoiceData.CustomerEmail);
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ctx.SaveChangesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
goto retry;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await ctx.SaveChangesAsync().ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateInvoiceExpiry(string invoiceId, TimeSpan seconds)
|
public async Task UpdateInvoiceExpiry(string invoiceId, TimeSpan seconds)
|
||||||
{
|
{
|
||||||
await using var ctx = _applicationDbContextFactory.CreateContext();
|
retry:
|
||||||
var invoiceData = await ctx.Invoices.FindAsync(invoiceId);
|
await using (var ctx = _applicationDbContextFactory.CreateContext())
|
||||||
var invoice = invoiceData.GetBlob(_btcPayNetworkProvider);
|
{
|
||||||
var expiry = DateTimeOffset.Now + seconds;
|
var invoiceData = await ctx.Invoices.FindAsync(invoiceId);
|
||||||
invoice.ExpirationTime = expiry;
|
var invoice = invoiceData.GetBlob(_btcPayNetworkProvider);
|
||||||
invoice.MonitoringExpiration = expiry.AddHours(1);
|
var expiry = DateTimeOffset.Now + seconds;
|
||||||
invoiceData.SetBlob(invoice);
|
invoice.ExpirationTime = expiry;
|
||||||
|
invoice.MonitoringExpiration = expiry.AddHours(1);
|
||||||
await ctx.SaveChangesAsync();
|
invoiceData.SetBlob(invoice);
|
||||||
|
try
|
||||||
_eventAggregator.Publish(new InvoiceDataChangedEvent(invoice));
|
{
|
||||||
_ = InvoiceNeedUpdateEventLater(invoiceId, seconds);
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
goto retry;
|
||||||
|
}
|
||||||
|
_eventAggregator.Publish(new InvoiceDataChangedEvent(invoice));
|
||||||
|
_ = InvoiceNeedUpdateEventLater(invoiceId, seconds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task InvoiceNeedUpdateEventLater(string invoiceId, TimeSpan expirationIn)
|
async Task InvoiceNeedUpdateEventLater(string invoiceId, TimeSpan expirationIn)
|
||||||
@@ -166,13 +185,23 @@ namespace BTCPayServer.Services.Invoices
|
|||||||
|
|
||||||
public async Task ExtendInvoiceMonitor(string invoiceId)
|
public async Task ExtendInvoiceMonitor(string invoiceId)
|
||||||
{
|
{
|
||||||
using var ctx = _applicationDbContextFactory.CreateContext();
|
retry:
|
||||||
var invoiceData = await ctx.Invoices.FindAsync(invoiceId);
|
using (var ctx = _applicationDbContextFactory.CreateContext())
|
||||||
|
{
|
||||||
|
var invoiceData = await ctx.Invoices.FindAsync(invoiceId);
|
||||||
|
|
||||||
var invoice = invoiceData.GetBlob(_btcPayNetworkProvider);
|
var invoice = invoiceData.GetBlob(_btcPayNetworkProvider);
|
||||||
invoice.MonitoringExpiration = invoice.MonitoringExpiration.AddHours(1);
|
invoice.MonitoringExpiration = invoice.MonitoringExpiration.AddHours(1);
|
||||||
invoiceData.SetBlob(invoice);
|
invoiceData.SetBlob(invoice);
|
||||||
await ctx.SaveChangesAsync();
|
try
|
||||||
|
{
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
goto retry;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateInvoiceAsync(InvoiceEntity invoice, string[] additionalSearchTerms = null)
|
public async Task CreateInvoiceAsync(InvoiceEntity invoice, string[] additionalSearchTerms = null)
|
||||||
@@ -279,62 +308,81 @@ namespace BTCPayServer.Services.Invoices
|
|||||||
|
|
||||||
public async Task<bool> NewPaymentDetails(string invoiceId, IPaymentMethodDetails paymentMethodDetails, BTCPayNetworkBase network)
|
public async Task<bool> NewPaymentDetails(string invoiceId, IPaymentMethodDetails paymentMethodDetails, BTCPayNetworkBase network)
|
||||||
{
|
{
|
||||||
await using var context = _applicationDbContextFactory.CreateContext();
|
retry:
|
||||||
var invoice = (await context.Invoices.Where(i => i.Id == invoiceId).ToListAsync()).FirstOrDefault();
|
await using (var context = _applicationDbContextFactory.CreateContext())
|
||||||
if (invoice == null)
|
{
|
||||||
return false;
|
var invoice = (await context.Invoices.Where(i => i.Id == invoiceId).ToListAsync()).FirstOrDefault();
|
||||||
|
if (invoice == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
var invoiceEntity = invoice.GetBlob(_btcPayNetworkProvider);
|
var invoiceEntity = invoice.GetBlob(_btcPayNetworkProvider);
|
||||||
var paymentMethod = invoiceEntity.GetPaymentMethod(network, paymentMethodDetails.GetPaymentType());
|
var paymentMethod = invoiceEntity.GetPaymentMethod(network, paymentMethodDetails.GetPaymentType());
|
||||||
if (paymentMethod == null)
|
if (paymentMethod == null)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
var existingPaymentMethod = paymentMethod.GetPaymentMethodDetails();
|
var existingPaymentMethod = paymentMethod.GetPaymentMethodDetails();
|
||||||
paymentMethod.SetPaymentMethodDetails(paymentMethodDetails);
|
paymentMethod.SetPaymentMethodDetails(paymentMethodDetails);
|
||||||
#pragma warning disable CS0618
|
#pragma warning disable CS0618
|
||||||
if (network.IsBTC)
|
if (network.IsBTC)
|
||||||
{
|
{
|
||||||
invoiceEntity.DepositAddress = paymentMethod.DepositAddress;
|
invoiceEntity.DepositAddress = paymentMethod.DepositAddress;
|
||||||
}
|
}
|
||||||
#pragma warning restore CS0618
|
#pragma warning restore CS0618
|
||||||
invoiceEntity.SetPaymentMethod(paymentMethod);
|
invoiceEntity.SetPaymentMethod(paymentMethod);
|
||||||
invoice.SetBlob(invoiceEntity);
|
invoice.SetBlob(invoiceEntity);
|
||||||
|
|
||||||
await context.AddressInvoices.AddAsync(new AddressInvoiceData()
|
|
||||||
{
|
|
||||||
InvoiceDataId = invoiceId,
|
|
||||||
CreatedTime = DateTimeOffset.UtcNow
|
|
||||||
}
|
|
||||||
.Set(GetDestination(paymentMethod), paymentMethod.GetId()));
|
|
||||||
|
|
||||||
AddToTextSearch(context, invoice, paymentMethodDetails.GetPaymentDestination());
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task UpdateInvoicePaymentMethod(string invoiceId, PaymentMethod paymentMethod)
|
|
||||||
{
|
|
||||||
using var context = _applicationDbContextFactory.CreateContext();
|
|
||||||
var invoice = await context.Invoices.FindAsync(invoiceId);
|
|
||||||
if (invoice == null)
|
|
||||||
return;
|
|
||||||
var network = paymentMethod.Network;
|
|
||||||
var invoiceEntity = invoice.GetBlob(_btcPayNetworkProvider);
|
|
||||||
var newDetails = paymentMethod.GetPaymentMethodDetails();
|
|
||||||
var existing = invoiceEntity.GetPaymentMethod(paymentMethod.GetId());
|
|
||||||
if (existing.GetPaymentMethodDetails().GetPaymentDestination() != newDetails.GetPaymentDestination() && newDetails.Activated)
|
|
||||||
{
|
|
||||||
await context.AddressInvoices.AddAsync(new AddressInvoiceData()
|
await context.AddressInvoices.AddAsync(new AddressInvoiceData()
|
||||||
{
|
{
|
||||||
InvoiceDataId = invoiceId,
|
InvoiceDataId = invoiceId,
|
||||||
CreatedTime = DateTimeOffset.UtcNow
|
CreatedTime = DateTimeOffset.UtcNow
|
||||||
}
|
}
|
||||||
.Set(GetDestination(paymentMethod), paymentMethod.GetId()));
|
.Set(GetDestination(paymentMethod), paymentMethod.GetId()));
|
||||||
|
|
||||||
|
AddToTextSearch(context, invoice, paymentMethodDetails.GetPaymentDestination());
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
goto retry;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateInvoicePaymentMethod(string invoiceId, PaymentMethod paymentMethod)
|
||||||
|
{
|
||||||
|
retry:
|
||||||
|
using (var context = _applicationDbContextFactory.CreateContext())
|
||||||
|
{
|
||||||
|
var invoice = await context.Invoices.FindAsync(invoiceId);
|
||||||
|
if (invoice == null)
|
||||||
|
return;
|
||||||
|
var network = paymentMethod.Network;
|
||||||
|
var invoiceEntity = invoice.GetBlob(_btcPayNetworkProvider);
|
||||||
|
var newDetails = paymentMethod.GetPaymentMethodDetails();
|
||||||
|
var existing = invoiceEntity.GetPaymentMethod(paymentMethod.GetId());
|
||||||
|
if (existing.GetPaymentMethodDetails().GetPaymentDestination() != newDetails.GetPaymentDestination() && newDetails.Activated)
|
||||||
|
{
|
||||||
|
await context.AddressInvoices.AddAsync(new AddressInvoiceData()
|
||||||
|
{
|
||||||
|
InvoiceDataId = invoiceId,
|
||||||
|
CreatedTime = DateTimeOffset.UtcNow
|
||||||
|
}
|
||||||
|
.Set(GetDestination(paymentMethod), paymentMethod.GetId()));
|
||||||
|
}
|
||||||
|
invoiceEntity.SetPaymentMethod(paymentMethod);
|
||||||
|
invoice.SetBlob(invoiceEntity);
|
||||||
|
AddToTextSearch(context, invoice, paymentMethod.GetPaymentMethodDetails().GetPaymentDestination());
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
goto retry;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
invoiceEntity.SetPaymentMethod(paymentMethod);
|
|
||||||
invoice.SetBlob(invoiceEntity);
|
|
||||||
AddToTextSearch(context, invoice, paymentMethod.GetPaymentMethodDetails().GetPaymentDestination());
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AddPendingInvoiceIfNotPresent(string invoiceId)
|
public async Task AddPendingInvoiceIfNotPresent(string invoiceId)
|
||||||
@@ -389,26 +437,38 @@ namespace BTCPayServer.Services.Invoices
|
|||||||
public async Task UpdateInvoiceStatus(string invoiceId, InvoiceState invoiceState)
|
public async Task UpdateInvoiceStatus(string invoiceId, InvoiceState invoiceState)
|
||||||
{
|
{
|
||||||
using var context = _applicationDbContextFactory.CreateContext();
|
using var context = _applicationDbContextFactory.CreateContext();
|
||||||
var invoiceData = await context.FindAsync<Data.InvoiceData>(invoiceId).ConfigureAwait(false);
|
await context.Database.GetDbConnection()
|
||||||
if (invoiceData == null)
|
.ExecuteAsync("UPDATE \"Invoices\" SET \"Status\"=@status, \"ExceptionStatus\"=@exstatus WHERE \"Id\"=@id",
|
||||||
return;
|
new
|
||||||
invoiceData.Status = InvoiceState.ToString(invoiceState.Status);
|
{
|
||||||
invoiceData.ExceptionStatus = InvoiceState.ToString(invoiceState.ExceptionStatus);
|
id = invoiceId,
|
||||||
await context.SaveChangesAsync().ConfigureAwait(false);
|
status = InvoiceState.ToString(invoiceState.Status),
|
||||||
|
exstatus = InvoiceState.ToString(invoiceState.ExceptionStatus)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
internal async Task UpdateInvoicePrice(string invoiceId, InvoiceEntity invoice)
|
internal async Task UpdateInvoicePrice(string invoiceId, decimal price)
|
||||||
{
|
{
|
||||||
if (invoice.Type != InvoiceType.TopUp)
|
retry:
|
||||||
throw new ArgumentException("The invoice type should be TopUp to be able to update invoice price", nameof(invoice));
|
using (var context = _applicationDbContextFactory.CreateContext())
|
||||||
using var context = _applicationDbContextFactory.CreateContext();
|
{
|
||||||
var invoiceData = await context.FindAsync<Data.InvoiceData>(invoiceId).ConfigureAwait(false);
|
var invoiceData = await context.FindAsync<Data.InvoiceData>(invoiceId).ConfigureAwait(false);
|
||||||
if (invoiceData == null)
|
if (invoiceData == null)
|
||||||
return;
|
return;
|
||||||
var blob = invoiceData.GetBlob(_btcPayNetworkProvider);
|
var blob = invoiceData.GetBlob(_btcPayNetworkProvider);
|
||||||
blob.Price = invoice.Price;
|
if (blob.Type != InvoiceType.TopUp)
|
||||||
AddToTextSearch(context, invoiceData, new[] { invoice.Price.ToString(CultureInfo.InvariantCulture) });
|
throw new ArgumentException("The invoice type should be TopUp to be able to update invoice price", nameof(invoiceId));
|
||||||
invoiceData.SetBlob(blob);
|
blob.Price = price;
|
||||||
await context.SaveChangesAsync().ConfigureAwait(false);
|
AddToTextSearch(context, invoiceData, new[] { price.ToString(CultureInfo.InvariantCulture) });
|
||||||
|
invoiceData.SetBlob(blob);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
goto retry;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MassArchive(string[] invoiceIds, bool archive = true)
|
public async Task MassArchive(string[] invoiceIds, bool archive = true)
|
||||||
@@ -436,37 +496,47 @@ namespace BTCPayServer.Services.Invoices
|
|||||||
}
|
}
|
||||||
public async Task<InvoiceEntity> UpdateInvoiceMetadata(string invoiceId, string storeId, JObject metadata)
|
public async Task<InvoiceEntity> UpdateInvoiceMetadata(string invoiceId, string storeId, JObject metadata)
|
||||||
{
|
{
|
||||||
using var context = _applicationDbContextFactory.CreateContext();
|
retry:
|
||||||
var invoiceData = await GetInvoiceRaw(invoiceId, context);
|
using (var context = _applicationDbContextFactory.CreateContext())
|
||||||
if (invoiceData == null || (storeId != null &&
|
|
||||||
!invoiceData.StoreDataId.Equals(storeId,
|
|
||||||
StringComparison.InvariantCultureIgnoreCase)))
|
|
||||||
return null;
|
|
||||||
var blob = invoiceData.GetBlob(_btcPayNetworkProvider);
|
|
||||||
|
|
||||||
var newMetadata = InvoiceMetadata.FromJObject(metadata);
|
|
||||||
var oldOrderId = blob.Metadata.OrderId;
|
|
||||||
var newOrderId = newMetadata.OrderId;
|
|
||||||
|
|
||||||
if (newOrderId != oldOrderId)
|
|
||||||
{
|
{
|
||||||
// OrderId is saved in 2 places: (1) the invoice table and (2) in the metadata field. We are updating both for consistency.
|
var invoiceData = await GetInvoiceRaw(invoiceId, context);
|
||||||
invoiceData.OrderId = newOrderId;
|
if (invoiceData == null || (storeId != null &&
|
||||||
|
!invoiceData.StoreDataId.Equals(storeId,
|
||||||
|
StringComparison.InvariantCultureIgnoreCase)))
|
||||||
|
return null;
|
||||||
|
var blob = invoiceData.GetBlob(_btcPayNetworkProvider);
|
||||||
|
|
||||||
if (oldOrderId != null && (newOrderId is null || !newOrderId.Equals(oldOrderId, StringComparison.InvariantCulture)))
|
var newMetadata = InvoiceMetadata.FromJObject(metadata);
|
||||||
|
var oldOrderId = blob.Metadata.OrderId;
|
||||||
|
var newOrderId = newMetadata.OrderId;
|
||||||
|
|
||||||
|
if (newOrderId != oldOrderId)
|
||||||
{
|
{
|
||||||
RemoveFromTextSearch(context, invoiceData, oldOrderId);
|
// OrderId is saved in 2 places: (1) the invoice table and (2) in the metadata field. We are updating both for consistency.
|
||||||
|
invoiceData.OrderId = newOrderId;
|
||||||
|
|
||||||
|
if (oldOrderId != null && (newOrderId is null || !newOrderId.Equals(oldOrderId, StringComparison.InvariantCulture)))
|
||||||
|
{
|
||||||
|
RemoveFromTextSearch(context, invoiceData, oldOrderId);
|
||||||
|
}
|
||||||
|
if (newOrderId != null)
|
||||||
|
{
|
||||||
|
AddToTextSearch(context, invoiceData, new[] { newOrderId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (newOrderId != null)
|
|
||||||
|
blob.Metadata = newMetadata;
|
||||||
|
invoiceData.SetBlob(blob);
|
||||||
|
try
|
||||||
{
|
{
|
||||||
AddToTextSearch(context, invoiceData, new[] { newOrderId });
|
await context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
catch (DbUpdateConcurrencyException)
|
||||||
|
{
|
||||||
|
goto retry;
|
||||||
|
}
|
||||||
|
return ToEntity(invoiceData);
|
||||||
}
|
}
|
||||||
|
|
||||||
blob.Metadata = newMetadata;
|
|
||||||
invoiceData.SetBlob(blob);
|
|
||||||
await context.SaveChangesAsync().ConfigureAwait(false);
|
|
||||||
return ToEntity(invoiceData);
|
|
||||||
}
|
}
|
||||||
public async Task<bool> MarkInvoiceStatus(string invoiceId, InvoiceStatus status)
|
public async Task<bool> MarkInvoiceStatus(string invoiceId, InvoiceStatus status)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user