Can attach labels and comment to transaction in the wallet

This commit is contained in:
nicolas.dorier 2019-08-03 00:42:30 +09:00
parent c8025ebaac
commit d79fda166f
No known key found for this signature in database
GPG Key ID: 6618763EF09186FE
13 changed files with 1434 additions and 3 deletions

View File

@ -727,7 +727,7 @@ namespace BTCPayServer.Tests
[Fact] [Fact]
[Trait("Integration", "Integration")] [Trait("Integration", "Integration")]
public void CanRescanWallet() public async Task CanRescanWallet()
{ {
using (var tester = ServerTester.Create()) using (var tester = ServerTester.Create())
{ {
@ -789,6 +789,32 @@ namespace BTCPayServer.Tests
transactions = Assert.IsType<ListTransactionsViewModel>(Assert.IsType<ViewResult>(walletController.WalletTransactions(walletId).Result).Model); transactions = Assert.IsType<ListTransactionsViewModel>(Assert.IsType<ViewResult>(walletController.WalletTransactions(walletId).Result).Model);
var tx = Assert.Single(transactions.Transactions); var tx = Assert.Single(transactions.Transactions);
Assert.Equal(tx.Id, txId.ToString()); Assert.Equal(tx.Id, txId.ToString());
// Hijack the test to see if we can add label and comments
Assert.IsType<RedirectToActionResult>(await walletController.ModifyTransaction(walletId, tx.Id, addlabel: "test"));
Assert.IsType<RedirectToActionResult>(await walletController.ModifyTransaction(walletId, tx.Id, addlabelclick: "test2"));
Assert.IsType<RedirectToActionResult>(await walletController.ModifyTransaction(walletId, tx.Id, addcomment: "hello"));
transactions = Assert.IsType<ListTransactionsViewModel>(Assert.IsType<ViewResult>(walletController.WalletTransactions(walletId).Result).Model);
tx = Assert.Single(transactions.Transactions);
Assert.Equal("hello", tx.Comment);
Assert.Contains("test", tx.Labels.Select(l => l.Value));
Assert.Contains("test2", tx.Labels.Select(l => l.Value));
Assert.Equal(2, tx.Labels.GroupBy(l => l.Color).Count());
Assert.IsType<RedirectToActionResult>(await walletController.ModifyTransaction(walletId, tx.Id, removelabel: "test2"));
transactions = Assert.IsType<ListTransactionsViewModel>(Assert.IsType<ViewResult>(walletController.WalletTransactions(walletId).Result).Model);
tx = Assert.Single(transactions.Transactions);
Assert.Equal("hello", tx.Comment);
Assert.Contains("test", tx.Labels.Select(l => l.Value));
Assert.DoesNotContain("test2", tx.Labels.Select(l => l.Value));
Assert.Single(tx.Labels.GroupBy(l => l.Color));
var walletInfo = await tester.PayTester.GetService<WalletRepository>().GetWalletInfo(walletId);
Assert.Single(walletInfo.LabelColors); // the test2 color should have been removed
} }
} }

View File

@ -39,6 +39,7 @@ namespace BTCPayServer.Controllers
public partial class WalletsController : Controller public partial class WalletsController : Controller
{ {
public StoreRepository Repository { get; } public StoreRepository Repository { get; }
public WalletRepository WalletRepository { get; }
public BTCPayNetworkProvider NetworkProvider { get; } public BTCPayNetworkProvider NetworkProvider { get; }
public ExplorerClientProvider ExplorerClientProvider { get; } public ExplorerClientProvider ExplorerClientProvider { get; }
@ -54,6 +55,7 @@ namespace BTCPayServer.Controllers
CurrencyNameTable _currencyTable; CurrencyNameTable _currencyTable;
public WalletsController(StoreRepository repo, public WalletsController(StoreRepository repo,
WalletRepository walletRepository,
CurrencyNameTable currencyTable, CurrencyNameTable currencyTable,
BTCPayNetworkProvider networkProvider, BTCPayNetworkProvider networkProvider,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
@ -66,6 +68,7 @@ namespace BTCPayServer.Controllers
{ {
_currencyTable = currencyTable; _currencyTable = currencyTable;
Repository = repo; Repository = repo;
WalletRepository = walletRepository;
RateFetcher = rateProvider; RateFetcher = rateProvider;
NetworkProvider = networkProvider; NetworkProvider = networkProvider;
_userManager = userManager; _userManager = userManager;
@ -76,6 +79,93 @@ namespace BTCPayServer.Controllers
_walletProvider = walletProvider; _walletProvider = walletProvider;
} }
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
string[] LabelColorScheme = new string[]
{
"#fbca04",
"#0e8a16",
"#ff7619",
"#84b6eb",
"#5319e7",
"#000000",
"#cc317c",
};
[HttpPost]
[Route("{walletId}")]
public async Task<IActionResult> ModifyTransaction(
// We need addlabel and addlabelclick. addlabel is the + button if the label does not exists,
// addlabelclick is if the user click on existing label. For some reason, reusing the same name attribute for both
// does not work
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, string transactionId, string addlabel = null, string addlabelclick = null, string addcomment = null, string removelabel = null)
{
addlabel = addlabel ?? addlabelclick;
DerivationSchemeSettings paymentMethod = await GetDerivationSchemeSettings(walletId);
if (paymentMethod == null)
return NotFound();
var walletBlobInfoAsync = WalletRepository.GetWalletInfo(walletId);
var walletTransactionsInfoAsync = WalletRepository.GetWalletTransactionsInfo(walletId);
var wallet = _walletProvider.GetWallet(paymentMethod.Network);
var walletBlobInfo = await walletBlobInfoAsync;
var walletTransactionsInfo = await walletTransactionsInfoAsync;
if (addlabel != null)
{
addlabel = addlabel.Trim().ToLowerInvariant();
var labels = walletBlobInfo.GetLabels();
if (!walletTransactionsInfo.TryGetValue(transactionId, out var walletTransactionInfo))
{
walletTransactionInfo = new WalletTransactionInfo();
}
if (!labels.Any(l => l.Value.Equals(addlabel, StringComparison.OrdinalIgnoreCase)))
{
List<string> allColors = new List<string>();
allColors.AddRange(LabelColorScheme);
allColors.AddRange(labels.Select(l => l.Color));
var chosenColor =
allColors
.GroupBy(k => k)
.OrderBy(k => k.Count())
.ThenBy(k => Array.IndexOf(LabelColorScheme, k.Key))
.First().Key;
walletBlobInfo.LabelColors.Add(addlabel, chosenColor);
await WalletRepository.SetWalletInfo(walletId, walletBlobInfo);
}
if (walletTransactionInfo.Labels.Add(addlabel))
{
await WalletRepository.SetWalletTransactionInfo(walletId, transactionId, walletTransactionInfo);
}
}
else if (removelabel != null)
{
removelabel = removelabel.Trim().ToLowerInvariant();
if (walletTransactionsInfo.TryGetValue(transactionId, out var walletTransactionInfo))
{
if (walletTransactionInfo.Labels.Remove(removelabel))
{
var canDelete = !walletTransactionsInfo.SelectMany(txi => txi.Value.Labels).Any(l => l == removelabel);
if (canDelete)
{
walletBlobInfo.LabelColors.Remove(removelabel);
await WalletRepository.SetWalletInfo(walletId, walletBlobInfo);
}
await WalletRepository.SetWalletTransactionInfo(walletId, transactionId, walletTransactionInfo);
}
}
}
else if (addcomment != null)
{
addcomment = addcomment.Trim();
if (!walletTransactionsInfo.TryGetValue(transactionId, out var walletTransactionInfo))
{
walletTransactionInfo = new WalletTransactionInfo();
}
walletTransactionInfo.Comment = addcomment;
await WalletRepository.SetWalletTransactionInfo(walletId, transactionId, walletTransactionInfo);
}
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
}
public async Task<IActionResult> ListWallets() public async Task<IActionResult> ListWallets()
{ {
var wallets = new ListWalletsViewModel(); var wallets = new ListWalletsViewModel();
@ -125,10 +215,13 @@ namespace BTCPayServer.Controllers
return NotFound(); return NotFound();
var wallet = _walletProvider.GetWallet(paymentMethod.Network); var wallet = _walletProvider.GetWallet(paymentMethod.Network);
var walletBlobAsync = WalletRepository.GetWalletInfo(walletId);
var walletTransactionsInfoAsync = WalletRepository.GetWalletTransactionsInfo(walletId);
var transactions = await wallet.FetchTransactions(paymentMethod.AccountDerivation); var transactions = await wallet.FetchTransactions(paymentMethod.AccountDerivation);
var walletBlob = await walletBlobAsync;
var walletTransactionsInfo = await walletTransactionsInfoAsync;
var model = new ListTransactionsViewModel(); var model = new ListTransactionsViewModel();
foreach (var tx in transactions.UnconfirmedTransactions.Transactions.Concat(transactions.ConfirmedTransactions.Transactions)) foreach (var tx in transactions.UnconfirmedTransactions.Transactions.Concat(transactions.ConfirmedTransactions.Transactions).ToArray())
{ {
var vm = new ListTransactionsViewModel.TransactionViewModel(); var vm = new ListTransactionsViewModel.TransactionViewModel();
model.Transactions.Add(vm); model.Transactions.Add(vm);
@ -138,11 +231,23 @@ namespace BTCPayServer.Controllers
vm.Positive = tx.BalanceChange >= Money.Zero; vm.Positive = tx.BalanceChange >= Money.Zero;
vm.Balance = tx.BalanceChange.ToString(); vm.Balance = tx.BalanceChange.ToString();
vm.IsConfirmed = tx.Confirmations != 0; vm.IsConfirmed = tx.Confirmations != 0;
if (walletTransactionsInfo.TryGetValue(tx.TransactionId.ToString(), out var transactionInfo))
{
var labels = walletBlob.GetLabels(transactionInfo);
vm.Labels.AddRange(labels);
model.Labels.AddRange(labels);
vm.Comment = transactionInfo.Comment;
}
} }
model.Transactions = model.Transactions.OrderByDescending(t => t.Timestamp).ToList(); model.Transactions = model.Transactions.OrderByDescending(t => t.Timestamp).ToList();
return View(model); return View(model);
} }
private static string GetLabelTarget(WalletId walletId, uint256 txId)
{
return $"{walletId}:{txId}";
}
[HttpGet] [HttpGet]
[Route("{walletId}/send")] [Route("{walletId}/send")]

View File

@ -61,6 +61,9 @@ namespace BTCPayServer.Data
get; set; get; set;
} }
public DbSet<WalletData> Wallets { get; set; }
public DbSet<WalletTransactionData> WalletTransactions { get; set; }
public DbSet<StoreData> Stores public DbSet<StoreData> Stores
{ {
get; set; get; set;
@ -231,6 +234,18 @@ namespace BTCPayServer.Data
builder.Entity<PaymentRequestData>() builder.Entity<PaymentRequestData>()
.HasIndex(o => o.Status); .HasIndex(o => o.Status);
builder.Entity<WalletTransactionData>()
.HasKey(o => new
{
o.WalletDataId,
#pragma warning disable CS0618
o.TransactionId
#pragma warning restore CS0618
});
builder.Entity<WalletTransactionData>()
.HasOne(o => o.WalletData)
.WithMany(w => w.WalletTransactions).OnDelete(DeleteBehavior.Cascade);
builder.UseOpenIddict<BTCPayOpenIdClient, BTCPayOpenIdAuthorization, OpenIddictScope<string>, BTCPayOpenIdToken, string>(); builder.UseOpenIddict<BTCPayOpenIdClient, BTCPayOpenIdAuthorization, OpenIddictScope<string>, BTCPayOpenIdToken, string>();
} }

View File

@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Newtonsoft.Json;
namespace BTCPayServer.Data
{
public class WalletData
{
[System.ComponentModel.DataAnnotations.Key]
public string Id { get; set; }
public List<WalletTransactionData> WalletTransactions { get; set; }
public byte[] Blob { get; set; }
public WalletBlobInfo GetBlobInfo()
{
if (Blob == null || Blob.Length == 0)
{
return new WalletBlobInfo();
}
var blobInfo = JsonConvert.DeserializeObject<WalletBlobInfo>(ZipUtils.Unzip(Blob));
return blobInfo;
}
public void SetBlobInfo(WalletBlobInfo blobInfo)
{
if (blobInfo == null)
{
Blob = Array.Empty<byte>();
return;
}
Blob = ZipUtils.Zip(JsonConvert.SerializeObject(blobInfo));
}
}
public class Label
{
public Label(string value, string color)
{
if (value == null)
throw new ArgumentNullException(nameof(value));
if (color == null)
throw new ArgumentNullException(nameof(color));
Value = value;
Color = color;
}
public string Value { get; }
public string Color { get; }
public override bool Equals(object obj)
{
Label item = obj as Label;
if (item == null)
return false;
return Value.Equals(item.Value, StringComparison.OrdinalIgnoreCase);
}
public static bool operator ==(Label a, Label b)
{
if (System.Object.ReferenceEquals(a, b))
return true;
if (((object)a == null) || ((object)b == null))
return false;
return a.Value == b.Value;
}
public static bool operator !=(Label a, Label b)
{
return !(a == b);
}
public override int GetHashCode()
{
return Value.GetHashCode(StringComparison.OrdinalIgnoreCase);
}
}
public class WalletBlobInfo
{
public Dictionary<string, string> LabelColors { get; set; } = new Dictionary<string, string>();
public IEnumerable<Label> GetLabels(WalletTransactionInfo transactionInfo)
{
foreach (var label in transactionInfo.Labels)
{
if (LabelColors.TryGetValue(label, out var color))
{
yield return new Label(label, color);
}
}
}
public IEnumerable<Label> GetLabels()
{
foreach (var kv in LabelColors)
{
yield return new Label(kv.Key, kv.Value);
}
}
}
}

View File

@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Newtonsoft.Json;
namespace BTCPayServer.Data
{
public class WalletTransactionData
{
public string WalletDataId { get; set; }
public WalletData WalletData { get; set; }
public string TransactionId { get; set; }
public string Labels { get; set; }
public byte[] Blob { get; set; }
public WalletTransactionInfo GetBlobInfo()
{
if (Blob == null || Blob.Length == 0)
{
return new WalletTransactionInfo();
}
var blobInfo = JsonConvert.DeserializeObject<WalletTransactionInfo>(ZipUtils.Unzip(Blob));
if (!string.IsNullOrEmpty(Labels))
{
blobInfo.Labels.AddRange(Labels.Split(',', StringSplitOptions.RemoveEmptyEntries));
}
return blobInfo;
}
public void SetBlobInfo(WalletTransactionInfo blobInfo)
{
if (blobInfo == null)
{
Labels = string.Empty;
Blob = Array.Empty<byte>();
return;
}
Labels = String.Join(',', blobInfo.Labels);
Blob = ZipUtils.Zip(JsonConvert.SerializeObject(blobInfo));
}
}
public class WalletTransactionInfo
{
public string Comment { get; set; } = string.Empty;
[JsonIgnore]
public HashSet<string> Labels { get; set; } = new HashSet<string>();
}
}

View File

@ -90,6 +90,7 @@ namespace BTCPayServer.Hosting
}); });
services.AddSingleton<BTCPayServerEnvironment>(); services.AddSingleton<BTCPayServerEnvironment>();
services.TryAddSingleton<TokenRepository>(); services.TryAddSingleton<TokenRepository>();
services.TryAddSingleton<WalletRepository>();
services.TryAddSingleton<EventAggregator>(); services.TryAddSingleton<EventAggregator>();
services.TryAddSingleton<PaymentRequestService>(); services.TryAddSingleton<PaymentRequestService>();
services.TryAddSingleton<U2FService>(); services.TryAddSingleton<U2FService>();

View File

@ -0,0 +1,885 @@
// <auto-generated />
using System;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20190802142637_WalletData")]
partial class WalletData
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "2.1.11-servicing-32099");
modelBuilder.Entity("BTCPayServer.Authentication.OpenId.Models.BTCPayOpenIdAuthorization", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<string>("ApplicationId");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50);
b.Property<string>("Properties");
b.Property<string>("Scopes");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(25);
b.Property<string>("Subject")
.IsRequired()
.HasMaxLength(450);
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(25);
b.HasKey("Id");
b.HasIndex("ApplicationId", "Status", "Subject", "Type");
b.ToTable("OpenIddictAuthorizations");
});
modelBuilder.Entity("BTCPayServer.Authentication.OpenId.Models.BTCPayOpenIdClient", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<string>("ApplicationUserId");
b.Property<string>("ClientId")
.IsRequired()
.HasMaxLength(100);
b.Property<string>("ClientSecret");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50);
b.Property<string>("ConsentType");
b.Property<string>("DisplayName");
b.Property<string>("Permissions");
b.Property<string>("PostLogoutRedirectUris");
b.Property<string>("Properties");
b.Property<string>("RedirectUris");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(25);
b.HasKey("Id");
b.HasIndex("ApplicationUserId");
b.HasIndex("ClientId")
.IsUnique();
b.ToTable("OpenIddictApplications");
});
modelBuilder.Entity("BTCPayServer.Authentication.OpenId.Models.BTCPayOpenIdToken", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<string>("ApplicationId");
b.Property<string>("AuthorizationId");
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50);
b.Property<DateTimeOffset?>("CreationDate");
b.Property<DateTimeOffset?>("ExpirationDate");
b.Property<string>("Payload");
b.Property<string>("Properties");
b.Property<string>("ReferenceId")
.HasMaxLength(100);
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(25);
b.Property<string>("Subject")
.IsRequired()
.HasMaxLength(450);
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(25);
b.HasKey("Id");
b.HasIndex("AuthorizationId");
b.HasIndex("ReferenceId")
.IsUnique();
b.HasIndex("ApplicationId", "Status", "Subject", "Type");
b.ToTable("OpenIddictTokens");
});
modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b =>
{
b.Property<string>("Address")
.ValueGeneratedOnAdd();
b.Property<DateTimeOffset?>("CreatedTime");
b.Property<string>("InvoiceDataId");
b.HasKey("Address");
b.HasIndex("InvoiceDataId");
b.ToTable("AddressInvoices");
});
modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasMaxLength(50);
b.Property<string>("StoreId")
.HasMaxLength(50);
b.HasKey("Id");
b.HasIndex("StoreId");
b.ToTable("ApiKeys");
});
modelBuilder.Entity("BTCPayServer.Data.AppData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<string>("AppType");
b.Property<DateTimeOffset>("Created");
b.Property<string>("Name");
b.Property<string>("Settings");
b.Property<string>("StoreDataId");
b.Property<bool>("TagAllInvoices");
b.HasKey("Id");
b.HasIndex("StoreDataId");
b.ToTable("Apps");
});
modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b =>
{
b.Property<string>("InvoiceDataId");
b.Property<string>("Address");
b.Property<DateTimeOffset>("Assigned");
b.Property<string>("CryptoCode");
b.Property<DateTimeOffset?>("UnAssigned");
b.HasKey("InvoiceDataId", "Address");
b.ToTable("HistoricalAddressInvoices");
});
modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<byte[]>("Blob");
b.Property<DateTimeOffset>("Created");
b.Property<string>("CustomerEmail");
b.Property<string>("ExceptionStatus");
b.Property<string>("ItemCode");
b.Property<string>("OrderId");
b.Property<string>("Status");
b.Property<string>("StoreDataId");
b.HasKey("Id");
b.HasIndex("StoreDataId");
b.ToTable("Invoices");
});
modelBuilder.Entity("BTCPayServer.Data.InvoiceEventData", b =>
{
b.Property<string>("InvoiceDataId");
b.Property<string>("UniqueId");
b.Property<string>("Message");
b.Property<DateTimeOffset>("Timestamp");
b.HasKey("InvoiceDataId", "UniqueId");
b.ToTable("InvoiceEvents");
});
modelBuilder.Entity("BTCPayServer.Data.PairedSINData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<string>("Label");
b.Property<DateTimeOffset>("PairingTime");
b.Property<string>("SIN");
b.Property<string>("StoreDataId");
b.HasKey("Id");
b.HasIndex("SIN");
b.HasIndex("StoreDataId");
b.ToTable("PairedSINData");
});
modelBuilder.Entity("BTCPayServer.Data.PairingCodeData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<DateTime>("DateCreated");
b.Property<DateTimeOffset>("Expiration");
b.Property<string>("Facade");
b.Property<string>("Label");
b.Property<string>("SIN");
b.Property<string>("StoreDataId");
b.Property<string>("TokenValue");
b.HasKey("Id");
b.ToTable("PairingCodes");
});
modelBuilder.Entity("BTCPayServer.Data.PaymentData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<bool>("Accounted");
b.Property<byte[]>("Blob");
b.Property<string>("InvoiceDataId");
b.HasKey("Id");
b.HasIndex("InvoiceDataId");
b.ToTable("Payments");
});
modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b =>
{
b.Property<string>("Id");
b.HasKey("Id");
b.ToTable("PendingInvoices");
});
modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<byte[]>("Blob");
b.Property<string>("InvoiceDataId");
b.HasKey("Id");
b.HasIndex("InvoiceDataId");
b.ToTable("RefundAddresses");
});
modelBuilder.Entity("BTCPayServer.Data.SettingData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<string>("Value");
b.HasKey("Id");
b.ToTable("Settings");
});
modelBuilder.Entity("BTCPayServer.Data.StoreData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<string>("DefaultCrypto");
b.Property<string>("DerivationStrategies");
b.Property<string>("DerivationStrategy");
b.Property<int>("SpeedPolicy");
b.Property<byte[]>("StoreBlob");
b.Property<byte[]>("StoreCertificate");
b.Property<string>("StoreName");
b.Property<string>("StoreWebsite");
b.HasKey("Id");
b.ToTable("Stores");
});
modelBuilder.Entity("BTCPayServer.Data.UserStore", b =>
{
b.Property<string>("ApplicationUserId");
b.Property<string>("StoreDataId");
b.Property<string>("Role");
b.HasKey("ApplicationUserId", "StoreDataId");
b.HasIndex("StoreDataId");
b.ToTable("UserStore");
});
modelBuilder.Entity("BTCPayServer.Data.WalletData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<byte[]>("Blob");
b.HasKey("Id");
b.ToTable("Wallets");
});
modelBuilder.Entity("BTCPayServer.Data.WalletTransactionData", b =>
{
b.Property<string>("WalletDataId");
b.Property<string>("TransactionId");
b.Property<byte[]>("Blob");
b.Property<string>("Labels");
b.HasKey("WalletDataId", "TransactionId");
b.ToTable("WalletTransactions");
});
modelBuilder.Entity("BTCPayServer.Models.ApplicationUser", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<int>("AccessFailedCount");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken();
b.Property<string>("Email")
.HasMaxLength(256);
b.Property<bool>("EmailConfirmed");
b.Property<bool>("LockoutEnabled");
b.Property<DateTimeOffset?>("LockoutEnd");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256);
b.Property<string>("NormalizedUserName")
.HasMaxLength(256);
b.Property<string>("PasswordHash");
b.Property<string>("PhoneNumber");
b.Property<bool>("PhoneNumberConfirmed");
b.Property<bool>("RequiresEmailConfirmation");
b.Property<string>("SecurityStamp");
b.Property<bool>("TwoFactorEnabled");
b.Property<string>("UserName")
.HasMaxLength(256);
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasName("UserNameIndex");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("BTCPayServer.Services.PaymentRequests.PaymentRequestData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<byte[]>("Blob");
b.Property<DateTimeOffset>("Created")
.ValueGeneratedOnAdd()
.HasDefaultValue(new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
b.Property<int>("Status");
b.Property<string>("StoreDataId");
b.HasKey("Id");
b.HasIndex("Status");
b.HasIndex("StoreDataId");
b.ToTable("PaymentRequests");
});
modelBuilder.Entity("BTCPayServer.Services.U2F.Models.U2FDevice", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<string>("ApplicationUserId");
b.Property<byte[]>("AttestationCert")
.IsRequired();
b.Property<int>("Counter");
b.Property<byte[]>("KeyHandle")
.IsRequired();
b.Property<string>("Name");
b.Property<byte[]>("PublicKey")
.IsRequired();
b.HasKey("Id");
b.HasIndex("ApplicationUserId");
b.ToTable("U2FDevices");
});
modelBuilder.Entity("BTCPayServer.Storage.Models.StoredFile", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<string>("ApplicationUserId");
b.Property<string>("FileName");
b.Property<string>("StorageFileName");
b.Property<DateTime>("Timestamp");
b.HasKey("Id");
b.HasIndex("ApplicationUserId");
b.ToTable("Files");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken();
b.Property<string>("Name")
.HasMaxLength(256);
b.Property<string>("NormalizedName")
.HasMaxLength(256);
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasName("RoleNameIndex");
b.ToTable("AspNetRoles");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd();
b.Property<string>("ClaimType");
b.Property<string>("ClaimValue");
b.Property<string>("RoleId")
.IsRequired();
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd();
b.Property<string>("ClaimType");
b.Property<string>("ClaimValue");
b.Property<string>("UserId")
.IsRequired();
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider");
b.Property<string>("ProviderKey");
b.Property<string>("ProviderDisplayName");
b.Property<string>("UserId")
.IsRequired();
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId");
b.Property<string>("RoleId");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId");
b.Property<string>("LoginProvider");
b.Property<string>("Name");
b.Property<string>("Value");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictScope<string>", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<string>("ConcurrencyToken")
.IsConcurrencyToken()
.HasMaxLength(50);
b.Property<string>("Description");
b.Property<string>("DisplayName");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200);
b.Property<string>("Properties");
b.Property<string>("Resources");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("OpenIddictScopes");
});
modelBuilder.Entity("BTCPayServer.Authentication.OpenId.Models.BTCPayOpenIdAuthorization", b =>
{
b.HasOne("BTCPayServer.Authentication.OpenId.Models.BTCPayOpenIdClient", "Application")
.WithMany("Authorizations")
.HasForeignKey("ApplicationId");
});
modelBuilder.Entity("BTCPayServer.Authentication.OpenId.Models.BTCPayOpenIdClient", b =>
{
b.HasOne("BTCPayServer.Models.ApplicationUser", "ApplicationUser")
.WithMany("OpenIdClients")
.HasForeignKey("ApplicationUserId");
});
modelBuilder.Entity("BTCPayServer.Authentication.OpenId.Models.BTCPayOpenIdToken", b =>
{
b.HasOne("BTCPayServer.Authentication.OpenId.Models.BTCPayOpenIdClient", "Application")
.WithMany("Tokens")
.HasForeignKey("ApplicationId");
b.HasOne("BTCPayServer.Authentication.OpenId.Models.BTCPayOpenIdAuthorization", "Authorization")
.WithMany("Tokens")
.HasForeignKey("AuthorizationId");
});
modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b =>
{
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")
.WithMany("AddressInvoices")
.HasForeignKey("InvoiceDataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
.WithMany("APIKeys")
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Data.AppData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
.WithMany("Apps")
.HasForeignKey("StoreDataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b =>
{
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")
.WithMany("HistoricalAddressInvoices")
.HasForeignKey("InvoiceDataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
.WithMany("Invoices")
.HasForeignKey("StoreDataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Data.InvoiceEventData", b =>
{
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")
.WithMany("Events")
.HasForeignKey("InvoiceDataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Data.PairedSINData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
.WithMany("PairedSINs")
.HasForeignKey("StoreDataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Data.PaymentData", b =>
{
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")
.WithMany("Payments")
.HasForeignKey("InvoiceDataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b =>
{
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")
.WithMany("PendingInvoices")
.HasForeignKey("Id")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b =>
{
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")
.WithMany("RefundAddresses")
.HasForeignKey("InvoiceDataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Data.UserStore", b =>
{
b.HasOne("BTCPayServer.Models.ApplicationUser", "ApplicationUser")
.WithMany("UserStores")
.HasForeignKey("ApplicationUserId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
.WithMany("UserStores")
.HasForeignKey("StoreDataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Data.WalletTransactionData", b =>
{
b.HasOne("BTCPayServer.Data.WalletData", "WalletData")
.WithMany("WalletTransactions")
.HasForeignKey("WalletDataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Services.PaymentRequests.PaymentRequestData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
.WithMany("PaymentRequests")
.HasForeignKey("StoreDataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Services.U2F.Models.U2FDevice", b =>
{
b.HasOne("BTCPayServer.Models.ApplicationUser", "ApplicationUser")
.WithMany("U2FDevices")
.HasForeignKey("ApplicationUserId");
});
modelBuilder.Entity("BTCPayServer.Storage.Models.StoredFile", b =>
{
b.HasOne("BTCPayServer.Models.ApplicationUser", "ApplicationUser")
.WithMany("StoredFiles")
.HasForeignKey("ApplicationUserId");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole")
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("BTCPayServer.Models.ApplicationUser")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("BTCPayServer.Models.ApplicationUser")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole")
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("BTCPayServer.Models.ApplicationUser")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("BTCPayServer.Models.ApplicationUser")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade);
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace BTCPayServer.Migrations
{
public partial class WalletData : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Wallets",
columns: table => new
{
Id = table.Column<string>(nullable: false),
Blob = table.Column<byte[]>(nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Wallets", x => x.Id);
});
migrationBuilder.CreateTable(
name: "WalletTransactions",
columns: table => new
{
WalletDataId = table.Column<string>(nullable: false),
TransactionId = table.Column<string>(nullable: false),
Labels = table.Column<string>(nullable: true),
Blob = table.Column<byte[]>(nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_WalletTransactions", x => new { x.WalletDataId, x.TransactionId });
table.ForeignKey(
name: "FK_WalletTransactions_Wallets_WalletDataId",
column: x => x.WalletDataId,
principalTable: "Wallets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "WalletTransactions");
migrationBuilder.DropTable(
name: "Wallets");
}
}
}

View File

@ -399,6 +399,33 @@ namespace BTCPayServer.Migrations
b.ToTable("UserStore"); b.ToTable("UserStore");
}); });
modelBuilder.Entity("BTCPayServer.Data.WalletData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd();
b.Property<byte[]>("Blob");
b.HasKey("Id");
b.ToTable("Wallets");
});
modelBuilder.Entity("BTCPayServer.Data.WalletTransactionData", b =>
{
b.Property<string>("WalletDataId");
b.Property<string>("TransactionId");
b.Property<byte[]>("Blob");
b.Property<string>("Labels");
b.HasKey("WalletDataId", "TransactionId");
b.ToTable("WalletTransactions");
});
modelBuilder.Entity("BTCPayServer.Models.ApplicationUser", b => modelBuilder.Entity("BTCPayServer.Models.ApplicationUser", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@ -776,6 +803,14 @@ namespace BTCPayServer.Migrations
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity("BTCPayServer.Data.WalletTransactionData", b =>
{
b.HasOne("BTCPayServer.Data.WalletData", "WalletData")
.WithMany("WalletTransactions")
.HasForeignKey("WalletDataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("BTCPayServer.Services.PaymentRequests.PaymentRequestData", b => modelBuilder.Entity("BTCPayServer.Services.PaymentRequests.PaymentRequestData", b =>
{ {
b.HasOne("BTCPayServer.Data.StoreData", "StoreData") b.HasOne("BTCPayServer.Data.StoreData", "StoreData")

View File

@ -2,6 +2,8 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Services;
namespace BTCPayServer.Models.WalletViewModels namespace BTCPayServer.Models.WalletViewModels
{ {
@ -11,11 +13,14 @@ namespace BTCPayServer.Models.WalletViewModels
{ {
public DateTimeOffset Timestamp { get; set; } public DateTimeOffset Timestamp { get; set; }
public bool IsConfirmed { get; set; } public bool IsConfirmed { get; set; }
public string Comment { get; set; }
public string Id { get; set; } public string Id { get; set; }
public string Link { get; set; } public string Link { get; set; }
public bool Positive { get; set; } public bool Positive { get; set; }
public string Balance { get; set; } public string Balance { get; set; }
public HashSet<Label> Labels { get; set; } = new HashSet<Label>();
} }
public HashSet<Label> Labels { get; set; } = new HashSet<Label>();
public List<TransactionViewModel> Transactions { get; set; } = new List<TransactionViewModel>(); public List<TransactionViewModel> Transactions { get; set; } = new List<TransactionViewModel>();
} }
} }

View File

@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Services
{
public class WalletRepository
{
private ApplicationDbContextFactory _ContextFactory;
public WalletRepository(ApplicationDbContextFactory contextFactory)
{
_ContextFactory = contextFactory ?? throw new ArgumentNullException(nameof(contextFactory));
}
public async Task SetWalletInfo(WalletId walletId, WalletBlobInfo blob)
{
if (walletId == null)
throw new ArgumentNullException(nameof(walletId));
using (var ctx = _ContextFactory.CreateContext())
{
var walletData = new WalletData() { Id = walletId.ToString() };
walletData.SetBlobInfo(blob);
var entity = await ctx.Wallets.AddAsync(walletData);
entity.State = EntityState.Modified;
try
{
await ctx.SaveChangesAsync();
}
catch (DbUpdateException) // Does not exists
{
entity.State = EntityState.Added;
await ctx.SaveChangesAsync();
}
}
}
public async Task<Dictionary<string, WalletTransactionInfo>> GetWalletTransactionsInfo(WalletId walletId)
{
if (walletId == null)
throw new ArgumentNullException(nameof(walletId));
using (var ctx = _ContextFactory.CreateContext())
{
return (await ctx.WalletTransactions
.Where(w => w.WalletDataId == walletId.ToString())
.Select(w => w)
.ToArrayAsync())
.ToDictionary(w => w.TransactionId, w => w.GetBlobInfo());
}
}
public async Task<WalletBlobInfo> GetWalletInfo(WalletId walletId)
{
if (walletId == null)
throw new ArgumentNullException(nameof(walletId));
using (var ctx = _ContextFactory.CreateContext())
{
var data = await ctx.Wallets
.Where(w => w.Id == walletId.ToString())
.Select(w => w)
.FirstOrDefaultAsync();
return data?.GetBlobInfo() ?? new WalletBlobInfo();
}
}
public async Task SetWalletTransactionInfo(WalletId walletId, string transactionId, WalletTransactionInfo walletTransactionInfo)
{
if (walletId == null)
throw new ArgumentNullException(nameof(walletId));
if (transactionId == null)
throw new ArgumentNullException(nameof(transactionId));
using (var ctx = _ContextFactory.CreateContext())
{
var walletData = new WalletTransactionData() { WalletDataId = walletId.ToString(), TransactionId = transactionId };
walletData.SetBlobInfo(walletTransactionInfo);
var entity = await ctx.WalletTransactions.AddAsync(walletData);
entity.State = EntityState.Modified;
try
{
await ctx.SaveChangesAsync();
}
catch (DbUpdateException) // Does not exists
{
entity.State = EntityState.Added;
await ctx.SaveChangesAsync();
}
}
}
}
}

View File

@ -6,6 +6,7 @@
} }
<style type="text/css"> <style type="text/css">
.smMaxWidth { .smMaxWidth {
max-width: 200px; max-width: 200px;
} }
@ -45,8 +46,10 @@
<span class="fa fa-clock-o" title="Switch date format"></span> <span class="fa fa-clock-o" title="Switch date format"></span>
</a> </a>
</th> </th>
<th style="text-align:left">Label</th>
<th>Transaction Id</th> <th>Transaction Id</th>
<th style="text-align:right">Balance</th> <th style="text-align:right">Balance</th>
<th style="text-align:right"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -58,6 +61,12 @@
@transaction.Timestamp.ToBrowserDate() @transaction.Timestamp.ToBrowserDate()
</span> </span>
</td> </td>
<td style="text-align:left">
@foreach (var label in transaction.Labels)
{
<span class="badge" style="display:block;background-color:@label.Color;color:white;">@label.Value</span>
}
</td>
<td class="smMaxWidth text-truncate"> <td class="smMaxWidth text-truncate">
<a href="@transaction.Link" target="_blank"> <a href="@transaction.Link" target="_blank">
@transaction.Id @transaction.Id
@ -71,6 +80,51 @@
{ {
<td style="text-align:right; color:red;">@transaction.Balance</td> <td style="text-align:right; color:red;">@transaction.Balance</td>
} }
<td style="text-align:right;">
<div class="dropdown" style="display:inline-block;">
<span class="fa fa-tags" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
<div class="dropdown-menu">
<form asp-action="ModifyTransaction" method="post">
<input type="hidden" name="transactionId" value="@transaction.Id" />
<div class="dropdown-item input-group">
<input name="addlabel" type="text" class="form-control form-control-sm" />
<div class="input-group-append">
<button type="submit" class="btn btn-primary btn-sm"><span class="fa fa-plus"></span></button>
</div>
</div>
<div class="dropdown-divider"></div>
@foreach (var label in Model.Labels)
{
@if (transaction.Labels.Contains(label))
{
<button name="removelabel" type="submit" class="dropdown-item" value="@label.Value"><span class="badge" style="display:block;background-color:@label.Color;color:white;text-align:left;"><span class="fa fa-check"></span> @label.Value</span></button>
}
else
{
<button name="addlabelclick" type="submit" class="dropdown-item" value="@label.Value"><span class="badge" style="display:block;background-color:@label.Color;color:white;text-align:left;">@label.Value</span></button>
}
}
</form>
</div>
</div>
<div class="dropdown" style="display:inline-block;">
@if (string.IsNullOrEmpty(transaction.Comment))
{
<span class="fa fa-comment" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
}
else
{
<span class="fa fa-commenting" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
}
<div class="dropdown-menu">
<form asp-action="ModifyTransaction" method="post">
<input type="hidden" name="transactionId" value="@transaction.Id" />
<textarea name="addcomment" class="dropdown-item" rows="2" cols="20">@transaction.Comment</textarea>
<div class="dropdown-item"><button type="submit" class="btn btn-primary">Save comment</button></div>
</form>
</div>
</div>
</td>
</tr> </tr>
} }
</tbody> </tbody>

View File

@ -43,6 +43,14 @@ namespace BTCPayServer
return false; return false;
return ToString().Equals(item.ToString(), StringComparison.InvariantCulture); return ToString().Equals(item.ToString(), StringComparison.InvariantCulture);
} }
public static WalletId Parse(string id)
{
if (TryParse(id, out var v))
return v;
throw new FormatException("Invalid WalletId");
}
public static bool operator ==(WalletId a, WalletId b) public static bool operator ==(WalletId a, WalletId b)
{ {
if (System.Object.ReferenceEquals(a, b)) if (System.Object.ReferenceEquals(a, b))