Wallet transactions export (#3744)

* Wallet transactions export

The exported data needs some more work.

* Fix transactions export policy

* Add test cases

* Fix Selenium warnings

* Finalize export format

* Test export download

* Remove CSV download check

* Try to fix test
This commit is contained in:
d11n
2022-05-20 02:35:31 +02:00
committed by GitHub
parent bb24ec4a9f
commit 3e95b59be8
6 changed files with 197 additions and 18 deletions

View File

@@ -46,7 +46,7 @@ namespace BTCPayServer.Tests
// Reset this using `dotnet user-secrets remove RunSeleniumInBrowser`
var chromeDriverPath = config["ChromeDriverDirectory"] ?? (Server.PayTester.InContainer ? "/usr/bin" : Directory.GetCurrentDirectory());
var options = new ChromeOptions();
if (!runInBrowser)
{
@@ -522,7 +522,7 @@ namespace BTCPayServer.Tests
walletId ??= WalletId;
GoToWallet(walletId, WalletsNavPages.Receive);
Driver.FindElement(By.Id("generateButton")).Click();
var addressStr = Driver.FindElement(By.Id("address")).GetProperty("value");
var addressStr = Driver.FindElement(By.Id("address")).GetAttribute("value");
var address = BitcoinAddress.Create(addressStr, ((BTCPayNetwork)Server.NetworkProvider.GetNetwork(walletId.CryptoCode)).NBitcoinNetwork);
for (var i = 0; i < coins; i++)
{

View File

@@ -809,7 +809,7 @@ namespace BTCPayServer.Tests
var walletId = new WalletId(storeId, "BTC");
s.GoToWallet(walletId, WalletsNavPages.Receive);
s.Driver.FindElement(By.Id("generateButton")).Click();
var addressStr = s.Driver.FindElement(By.Id("address")).GetProperty("value");
var addressStr = s.Driver.FindElement(By.Id("address")).GetAttribute("value");
var address = BitcoinAddress.Create(addressStr,
((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork);
await s.Server.ExplorerNode.GenerateAsync(1);
@@ -1149,6 +1149,25 @@ namespace BTCPayServer.Tests
Assert.Empty(s.Driver.FindElements(By.Id("confirm")));
s.Driver.FindElement(By.Id("proceed")).Click();
Assert.Equal(settingsUrl, s.Driver.Url);
// Transactions list contains export and action, ensure functions are present.
s.Driver.FindElement(By.Id($"StoreNav-Wallet{cryptoCode}")).Click();
s.Driver.FindElement(By.Id("ActionsDropdownToggle")).Click();
s.Driver.FindElement(By.Id("BumpFee"));
// JSON export
s.Driver.FindElement(By.Id("ExportDropdownToggle")).Click();
s.Driver.FindElement(By.Id("ExportJSON")).Click();
Thread.Sleep(1000);
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
Assert.Contains(s.WalletId.ToString(), s.Driver.Url);
Assert.EndsWith("export?format=json", s.Driver.Url);
Assert.Contains("\"Amount\": \"3.00000000\"", s.Driver.PageSource);
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
// CSV export
s.Driver.FindElement(By.Id("ExportDropdownToggle")).Click();
s.Driver.FindElement(By.Id("ExportCSV")).Click();
}
[Fact(Timeout = TestTimeout)]
@@ -1167,6 +1186,12 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("AccountKeys_0__MasterFingerprint")).GetAttribute("value"));
Assert.Contains("m/84'/1'/0'",
s.Driver.FindElement(By.Id("AccountKeys_0__AccountKeyPath")).GetAttribute("value"));
// Transactions list is empty
s.Driver.FindElement(By.Id($"StoreNav-Wallet{cryptoCode}")).Click();
Assert.Contains("There are no transactions yet.", s.Driver.PageSource);
s.Driver.AssertElementNotFound(By.Id("ExportDropdownToggle"));
s.Driver.AssertElementNotFound(By.Id("ActionsDropdownToggle"));
}
[Fact]

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
@@ -29,6 +30,7 @@ using Microsoft.Extensions.DependencyInjection;
using NBitcoin;
using BTCPayServer.Client.Models;
using BTCPayServer.Logging;
using BTCPayServer.Services.Wallets.Export;
using NBXplorer;
using NBXplorer.Client;
using NBXplorer.DerivationStrategy;
@@ -579,7 +581,6 @@ namespace BTCPayServer.Controllers
: null;
}
[HttpPost("{walletId}/send")]
public async Task<IActionResult> WalletSend(
[ModelBinder(typeof(WalletIdModelBinder))]
@@ -1278,6 +1279,36 @@ namespace BTCPayServer.Controllers
}
}
[HttpGet("{walletId}/export")]
public async Task<IActionResult> Export(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
string format, string labelFilter = null)
{
DerivationSchemeSettings paymentMethod = GetDerivationSchemeSettings(walletId);
if (paymentMethod == null)
return NotFound();
var wallet = _walletProvider.GetWallet(paymentMethod.Network);
var walletTransactionsInfoAsync = WalletRepository.GetWalletTransactionsInfo(walletId);
var transactions = await wallet.FetchTransactions(paymentMethod.AccountDerivation);
var walletTransactionsInfo = await walletTransactionsInfoAsync;
var input = transactions.UnconfirmedTransactions.Transactions
.Concat(transactions.ConfirmedTransactions.Transactions)
.OrderByDescending(t => t.Timestamp)
.ToList();
var export = new TransactionsExport(wallet, walletTransactionsInfo);
var res = export.Process(input, format);
var cd = new ContentDisposition
{
FileName = $"btcpay-{walletId}-{DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture)}.{format}",
Inline = true
};
Response.Headers.Add("Content-Disposition", cd.ToString());
Response.Headers.Add("X-Content-Type-Options", "nosniff");
return Content(res, "application/" + format);
}
private string GetImage(PaymentMethodId paymentMethodId, BTCPayNetwork network)
{
var res = paymentMethodId.PaymentType == PaymentTypes.BTCLike

View File

@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using CsvHelper.Configuration;
using CsvHelper.Configuration.Attributes;
using NBXplorer.Models;
using Newtonsoft.Json;
namespace BTCPayServer.Services.Wallets.Export
{
public class TransactionsExport
{
private readonly BTCPayWallet _wallet;
private readonly Dictionary<string, WalletTransactionInfo> _walletTransactionsInfo;
public TransactionsExport(BTCPayWallet wallet, Dictionary<string, WalletTransactionInfo> walletTransactionsInfo)
{
_wallet = wallet;
_walletTransactionsInfo = walletTransactionsInfo;
}
public string Process(IEnumerable<TransactionInformation> inputList, string fileFormat)
{
var list = inputList.Select(tx =>
{
var model = new ExportTransaction
{
TransactionId = tx.TransactionId.ToString(),
Timestamp = tx.Timestamp,
Amount = tx.BalanceChange.ShowMoney(_wallet.Network),
Currency = _wallet.Network.CryptoCode,
IsConfirmed = tx.Confirmations != 0
};
if (_walletTransactionsInfo.TryGetValue(tx.TransactionId.ToString(), out var transactionInfo))
{
model.Labels = transactionInfo.Labels?.Select(l => l.Value.Text).ToList();
model.Comment = transactionInfo.Comment;
}
return model;
}).ToList();
if (string.Equals(fileFormat, "json", StringComparison.OrdinalIgnoreCase))
return ProcessJson(list);
if (string.Equals(fileFormat, "csv", StringComparison.OrdinalIgnoreCase))
return ProcessCsv(list);
throw new Exception("Export format not supported");
}
private static string ProcessJson(List<ExportTransaction> invoices)
{
var serializerSett = new JsonSerializerSettings { ReferenceLoopHandling = ReferenceLoopHandling.Ignore };
var json = JsonConvert.SerializeObject(invoices, Formatting.Indented, serializerSett);
return json;
}
private static string ProcessCsv(IEnumerable<ExportTransaction> invoices)
{
using StringWriter writer = new();
using var csvWriter = new CsvHelper.CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture), true);
csvWriter.Configuration.RegisterClassMap<ExportTransactionMap>();
csvWriter.WriteHeader<ExportTransaction>();
csvWriter.NextRecord();
csvWriter.WriteRecords(invoices);
csvWriter.Flush();
return writer.ToString();
}
}
public sealed class ExportTransactionMap : ClassMap<ExportTransaction>
{
public ExportTransactionMap()
{
AutoMap(CultureInfo.InvariantCulture);
Map(m => m.Labels).ConvertUsing(row => row.Labels == null ? string.Empty : string.Join(", ", row.Labels));
}
}
public class ExportTransaction
{
[Name("Transaction Id")]
public string TransactionId { get; set; }
public DateTimeOffset Timestamp { get; set; }
public string Amount { get; set; }
public string Currency { get; set; }
[Name("Is Confirmed")]
public bool IsConfirmed { get; set; }
public string Comment { get; set; }
public List<string> Labels { get; set; }
}
}

View File

@@ -283,8 +283,8 @@
<form method="post" id="MassAction" asp-action="MassAction" class="">
<div class="d-inline-flex align-items-center pb-2 float-xl-end mb-2 gap-3">
<input type="hidden" name="storeId" value="@Model.StoreId" />
<span class="order-xl-1">
<button class="btn btn-secondary dropdown-toggle mb-1" type="button" id="ActionsDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<div class="dropdown order-xl-1">
<button class="btn btn-secondary dropdown-toggle" type="button" id="ActionsDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Actions
</button>
<div class="dropdown-menu dropdown-menu-xl-end" aria-labelledby="ActionsDropdownToggle">
@@ -295,11 +295,11 @@
}
<button id="BumpFee" type="submit" permission="@Policies.CanModifyStoreSettings" class="dropdown-item" name="command" value="cpfp">Bump fee</button>
</div>
</span>
<span class="d-inline-flex align-items-center gap-3">
<a class="btn btn-secondary dropdown-toggle mb-1 order-xl-1" href="#" role="button" id="ExportDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
</div>
<div class="dropdown d-inline-flex align-items-center gap-3">
<button class="btn btn-secondary dropdown-toggle order-xl-1" type="button" id="ExportDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Export
</a>
</button>
<div class="dropdown-menu" aria-labelledby="ExportDropdownToggle">
<a asp-action="Export" asp-route-timezoneoffset="0" asp-route-format="csv" asp-route-searchTerm="@Model.SearchTerm" class="dropdown-item export-link" target="_blank">CSV</a>
<a asp-action="Export" asp-route-timezoneoffset="0" asp-route-format="json" asp-route-searchTerm="@Model.SearchTerm" class="dropdown-item export-link" target="_blank">JSON</a>
@@ -307,7 +307,7 @@
<a href="https://docs.btcpayserver.org/Accounting/" target="_blank" rel="noreferrer noopener">
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>
</a>
</span>
</div>
</div>
<div style="clear:both"></div>
<div class="table-responsive">

View File

@@ -1,6 +1,7 @@
@model ListTransactionsViewModel
@{
var walletId = Context.GetRouteValue("walletId").ToString();
var labelFilter = Context.Request.Query["labelFilter"].ToString();
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(WalletsNavPages.Transactions, $"{Model.CryptoCode} Transactions", walletId);
}
@@ -47,6 +48,13 @@
background-color: transparent;
border: 0;
}
/* pull actions area, so that it is besides the search form */
@@media (min-width: 1200px) {
#Actions {
margin-top: -4rem;
}
}
</style>
}
@@ -67,10 +75,10 @@
@if (Model.Transactions.Any())
{
<div class="d-sm-flex align-items-center gap-3 mb-2">
@if (Model.Labels.Any())
{
<div class="input-group mb-4 mb-sm-0">
@if (Model.Labels.Any())
{
<div class="col-xl-7 col-xxl-8 mb-4">
<div class="input-group">
<span class="input-group-text">Filter</span>
<div class="form-control d-flex flex-wrap gap-2 align-items-center">
@foreach (var label in Model.Labels)
@@ -78,13 +86,15 @@
<a asp-route-labelFilter="@label.Text" class="badge position-relative text-white flex-grow-0" style="background-color:@label.Color;color:@label.TextColor !important;">@label.Text</a>
}
</div>
@if (Context.Request.Query.ContainsKey("labelFilter"))
@if (labelFilter != null)
{
<a asp-route-labelFilter="" class="btn btn-secondary d-flex align-items-center">Clear filter</a>
}
</div>
}
<div class="dropdown ms-auto">
</div>
}
<div class="d-inline-flex align-items-center pb-2 float-xl-end mb-2 gap-3" id="Actions">
<div class="dropdown order-xl-1 ms-auto">
<button class="btn btn-secondary dropdown-toggle" type="button" id="ActionsDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Actions
</button>
@@ -94,7 +104,20 @@
</form>
</div>
</div>
<div class="dropdown d-inline-flex align-items-center gap-3">
<button class="btn btn-secondary dropdown-toggle order-xl-1" type="button" id="ExportDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Export
</button>
<div class="dropdown-menu" aria-labelledby="ExportDropdownToggle">
<a asp-action="Export" asp-route-walletId="@walletId" asp-route-format="csv" asp-route-labelFilter="@labelFilter" class="dropdown-item export-link" target="_blank" id="ExportCSV">CSV</a>
<a asp-action="Export" asp-route-walletId="@walletId" asp-route-format="json" asp-route-labelFilter="@labelFilter" class="dropdown-item export-link" target="_blank" id="ExportJSON">JSON</a>
</div>
<a href="https://docs.btcpayserver.org/Accounting/" target="_blank" rel="noreferrer noopener">
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>
</a>
</div>
</div>
<div style="clear:both"></div>
<div class="row">
<div class="col table-responsive-md">