mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-03-04 01:53:52 +01:00
Custodian Account UI: CRUD (#3923)
* WIP New APIs for dealing with custodians/exchanges * Simplified things * More API refinements + index.html file for quick viewing * Finishing touches on spec * Switched cryptoCode to paymentMethod as this allows us to differentiate between onchain and lightning * Moved draft API docs to "/docs-draft" * WIP baby steps * Added DB migration for CustodianAccountData * Rough but working POST /v1/api/custodian-account + GET /v1/api/custodian * WIP + early Kraken API client * Moved service registration to proper location * Working create + list custodian accounts + permissions + WIP Kraken client * Kraken API Balances call is working * Added asset balances to response * List Custodian Accounts call does not load assetBalances by default, because it can fail. Can be requested when needed. * Call to get the details of 1 specific custodian account * Added permissions to swagger * Added "tradableAssetPairs" to Kraken custodian response + cache the tradable pairs in memory for 24 hours * Removed unused file * WIP + Moved files to better locations * Updated docs * Working API endpoint to get info on a trade (same response as creating a new trade) * Working API endpoints for Deposit + Trade + untested Withdraw * Delete custodian account * Trading works, better error handling, cleanup * Working withdrawals + New endpoint for getting bid/ask prices * Completed withdrawals + new endpoint for getting info on a past withdrawal to simplify testing, Enums are output as strings, * Better error handling when withdrawing to a wrong destination * WithdrawalAddressName in config is now a string per currency (dictionary) * Added TODOs * Only show the custodian account "config" to users who are allowed * Added the new permissions to the API Keys UI * Renamed KrakenClient to KrakenExchange * WIP Kraken Config Form * Removed files for UI again, will make separate PR later * Fixed docs + Refactored to use PaymentMethod more + Added "name" to custodian account + Using cancelationToken everywhere * Updated withdrawal info docs * First unit test * Complete tests for /api/v1/custodians and /api/v1/custodian-accounts endpoints + Various improvements and fixes * Mock custodian and more exceptions * Many more tests + cleanup, moved files to better locations * More tests * WIP more tests * Greenfield API tests complete * Added missing "Name" column * Cleanup, TODOs and beginning of Kraken Tests * Added Kraken tests using public endpoints + handling of "SATS" currency * Added 1st mocked Kraken API call: GetAssetBalancesAsync * Added assert for bad config * Mocked more Kraken API responses + added CreationDate to withdrawal response * pr review club changes * Make Kraken Custodian a plugin * Re-added User-Agent header as it is required * Fixed bug in market trade on Kraken using a percentage as qty * A short delay so Kraken has the time to execute the market order and we don't fetch the details too quickly. * Merged the draft swagger into the main swagger since it didn't work anymore * Fixed API permissions test * Removed 2 TODOs * Fixed unit test * After a utxo rescan, the cached balance should be invalidated * Fixed Kraken plugin build issues * Added Kraken plugin to build * WIP UI + config form * Create custodian account almost working - only need to add in the config form * Working form, but lacks refinement * Viewing balances + Editing custodian account works, but cannot change the withdrawal destination config because that is an object using a name with [] in it * cleanup * Minor cleanup, comments * Working: Delete custodian account * Moved the MockCustodian used in tests to a new plugin + linked it to the tests * WIP viewing custodian account balances * Split the Mock custodian into a Mock + Fake, various UI improvements and minor fixes * Minor UI fixes * Removed broken link * Removed links to anchors as they cannot pass the tests since they use JavaScript * Removed non-existing link. Even though it was commented out, the test still broke? * Added TODOs * Now throwing BadConfigException if API key is invalid * UI improvements * Commented out unfinished API endpoints. Can be finished later. * Show fiat value for fiat assets * Removed Kraken plugin so I can make a PR Removed more Kraken files * Add experimental route on UICustodianAccountsControllre * Removed unneeded code * Cleanup code * Processed Nicolas' feedback Co-authored-by: Kukks <evilkukka@gmail.com> Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
parent
35f97a6013
commit
2abc35058b
33 changed files with 1193 additions and 26 deletions
|
@ -1,6 +1,7 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace BTCPayServer.Abstractions.Custodians;
|
namespace BTCPayServer.Abstractions.Custodians;
|
||||||
|
@ -18,4 +19,8 @@ public interface ICustodian
|
||||||
* Get a list of assets and their qty in custody.
|
* Get a list of assets and their qty in custody.
|
||||||
*/
|
*/
|
||||||
Task<Dictionary<string, decimal>> GetAssetBalancesAsync(JObject config, CancellationToken cancellationToken);
|
Task<Dictionary<string, decimal>> GetAssetBalancesAsync(JObject config, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
public Task<Form.Form> GetConfigForm(JObject config, string locale,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
14
BTCPayServer.Abstractions/Extensions/CustodianExtensions.cs
Normal file
14
BTCPayServer.Abstractions/Extensions/CustodianExtensions.cs
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
#nullable enable
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using BTCPayServer.Abstractions.Custodians;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Abstractions.Extensions;
|
||||||
|
|
||||||
|
public static class CustodianExtensions
|
||||||
|
{
|
||||||
|
public static ICustodian? GetCustodianByCode(this IEnumerable<ICustodian> custodians, string code)
|
||||||
|
{
|
||||||
|
return custodians.FirstOrDefault(custodian => custodian.Code == code);
|
||||||
|
}
|
||||||
|
}
|
37
BTCPayServer.Abstractions/Form/AlertMessage.cs
Normal file
37
BTCPayServer.Abstractions/Form/AlertMessage.cs
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Converters;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Abstractions.Form;
|
||||||
|
|
||||||
|
public class AlertMessage
|
||||||
|
{
|
||||||
|
// Corresponds to the Bootstrap CSS "alert alert-xxx" messages:
|
||||||
|
// Success = green
|
||||||
|
// Warning = orange
|
||||||
|
// Danger = red
|
||||||
|
// Info = blue
|
||||||
|
public enum AlertMessageType
|
||||||
|
{
|
||||||
|
Success,
|
||||||
|
Warning,
|
||||||
|
Danger,
|
||||||
|
Info
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonConverter(typeof(StringEnumConverter))]
|
||||||
|
public AlertMessageType Type;
|
||||||
|
|
||||||
|
// The translated message to be shown to the user
|
||||||
|
public string Message;
|
||||||
|
|
||||||
|
public AlertMessage()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public AlertMessage(AlertMessageType type, string message)
|
||||||
|
{
|
||||||
|
this.Type = type;
|
||||||
|
this.Message = message;
|
||||||
|
}
|
||||||
|
}
|
35
BTCPayServer.Abstractions/Form/Field.cs
Normal file
35
BTCPayServer.Abstractions/Form/Field.cs
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Abstractions.Form;
|
||||||
|
|
||||||
|
public abstract class Field
|
||||||
|
{
|
||||||
|
// HTML5 compatible type string like "text", "textarea", "email", "password", etc. Each type is a class and may contain more fields (i.e. "select" would have options).
|
||||||
|
public string Type;
|
||||||
|
|
||||||
|
// The name of the HTML5 node. Should be used as the key for the posted data.
|
||||||
|
public string Name;
|
||||||
|
|
||||||
|
// The translated label of the field.
|
||||||
|
public string Label;
|
||||||
|
|
||||||
|
// The value field is what is currently in the DB or what the user entered, but possibly not saved yet due to validation errors.
|
||||||
|
// If this is the first the user sees the form, then value and original value are the same. Value changes as the user starts interacting with the form.
|
||||||
|
public string Value;
|
||||||
|
|
||||||
|
// The original value is the value that is currently saved in the backend. A "reset" button can be used to revert back to this. Should only be set from the constructor.
|
||||||
|
public string OriginalValue;
|
||||||
|
|
||||||
|
// A useful note shown below the field or via a tooltip / info icon. Should be translated for the user.
|
||||||
|
public string HelpText;
|
||||||
|
|
||||||
|
// The field is considered "valid" if there are no validation errors
|
||||||
|
public List<string> ValidationErrors = new List<string>();
|
||||||
|
|
||||||
|
public bool Required = false;
|
||||||
|
|
||||||
|
public bool IsValid()
|
||||||
|
{
|
||||||
|
return ValidationErrors.Count == 0;
|
||||||
|
}
|
||||||
|
}
|
14
BTCPayServer.Abstractions/Form/Fieldset.cs
Normal file
14
BTCPayServer.Abstractions/Form/Fieldset.cs
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Abstractions.Form;
|
||||||
|
|
||||||
|
public class Fieldset
|
||||||
|
{
|
||||||
|
public Fieldset()
|
||||||
|
{
|
||||||
|
this.Fields = new List<Field>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Label { get; set; }
|
||||||
|
public List<Field> Fields { get; set; }
|
||||||
|
}
|
60
BTCPayServer.Abstractions/Form/Form.cs
Normal file
60
BTCPayServer.Abstractions/Form/Form.cs
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Abstractions.Form;
|
||||||
|
|
||||||
|
public class Form
|
||||||
|
{
|
||||||
|
|
||||||
|
// Messages to be shown at the top of the form indicating user feedback like "Saved successfully" or "Please change X because of Y." or a warning, etc...
|
||||||
|
public List<AlertMessage> TopMessages { get; set; } = new();
|
||||||
|
|
||||||
|
// Groups of fields in the form
|
||||||
|
public List<Fieldset> Fieldsets { get; set; } = new();
|
||||||
|
|
||||||
|
|
||||||
|
// Are all the fields valid in the form?
|
||||||
|
public bool IsValid()
|
||||||
|
{
|
||||||
|
foreach (var fieldset in Fieldsets)
|
||||||
|
{
|
||||||
|
foreach (var field in fieldset.Fields)
|
||||||
|
{
|
||||||
|
if (!field.IsValid())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Field GetFieldByName(string name)
|
||||||
|
{
|
||||||
|
foreach (var fieldset in Fieldsets)
|
||||||
|
{
|
||||||
|
foreach (var field in fieldset.Fields)
|
||||||
|
{
|
||||||
|
if (name.Equals(field.Name))
|
||||||
|
{
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<string> GetAllNames()
|
||||||
|
{
|
||||||
|
var names = new List<string>();
|
||||||
|
foreach (var fieldset in Fieldsets)
|
||||||
|
{
|
||||||
|
foreach (var field in fieldset.Fields)
|
||||||
|
{
|
||||||
|
names.Add(field.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
}
|
19
BTCPayServer.Abstractions/Form/TextField.cs
Normal file
19
BTCPayServer.Abstractions/Form/TextField.cs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
namespace BTCPayServer.Abstractions.Form;
|
||||||
|
|
||||||
|
public class TextField : Field
|
||||||
|
{
|
||||||
|
public TextField(string label, string name, string value, bool required, string helpText)
|
||||||
|
{
|
||||||
|
this.Label = label;
|
||||||
|
this.Name = name;
|
||||||
|
this.Value = value;
|
||||||
|
this.OriginalValue = value;
|
||||||
|
this.Required = required;
|
||||||
|
this.HelpText = helpText;
|
||||||
|
this.Type = "text";
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO JSON parsing from string to objects again probably won't work out of the box because of the different field types.
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -19,6 +19,7 @@ public class CustodianAccountData
|
||||||
[MaxLength(50)]
|
[MaxLength(50)]
|
||||||
public string CustodianCode { get; set; }
|
public string CustodianCode { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
[MaxLength(50)]
|
[MaxLength(50)]
|
||||||
public string Name { get; set; }
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
|
|
@ -1,28 +1,23 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Abstractions.Contracts;
|
using BTCPayServer.Abstractions.Contracts;
|
||||||
using BTCPayServer.Abstractions.Custodians;
|
using BTCPayServer.Abstractions.Custodians;
|
||||||
using BTCPayServer.Abstractions.Extensions;
|
|
||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
using BTCPayServer.Controllers;
|
using BTCPayServer.Controllers;
|
||||||
using BTCPayServer.Events;
|
using BTCPayServer.Events;
|
||||||
using BTCPayServer.JsonConverters;
|
|
||||||
using BTCPayServer.Lightning;
|
using BTCPayServer.Lightning;
|
||||||
using BTCPayServer.Models.InvoicingModels;
|
using BTCPayServer.Models.InvoicingModels;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
using BTCPayServer.Services;
|
|
||||||
using BTCPayServer.Services.Custodian.Client;
|
|
||||||
using BTCPayServer.Services.Custodian.Client.MockCustodian;
|
using BTCPayServer.Services.Custodian.Client.MockCustodian;
|
||||||
|
using BTCPayServer.Services;
|
||||||
using BTCPayServer.Services.Notifications;
|
using BTCPayServer.Services.Notifications;
|
||||||
using BTCPayServer.Services.Notifications.Blobs;
|
using BTCPayServer.Services.Notifications.Blobs;
|
||||||
using BTCPayServer.Tests.Logging;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
|
@ -31,9 +26,7 @@ using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using Xunit.Abstractions;
|
using Xunit.Abstractions;
|
||||||
using Xunit.Sdk;
|
|
||||||
using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest;
|
using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest;
|
||||||
using JsonReader = Newtonsoft.Json.JsonReader;
|
|
||||||
|
|
||||||
namespace BTCPayServer.Tests
|
namespace BTCPayServer.Tests
|
||||||
{
|
{
|
||||||
|
@ -2586,7 +2579,7 @@ namespace BTCPayServer.Tests
|
||||||
var clientBasic = await user.CreateClient();
|
var clientBasic = await user.CreateClient();
|
||||||
var custodians = await clientBasic.GetCustodians();
|
var custodians = await clientBasic.GetCustodians();
|
||||||
Assert.NotNull(custodians);
|
Assert.NotNull(custodians);
|
||||||
Assert.Single(custodians);
|
Assert.NotEmpty(custodians);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -2680,13 +2673,23 @@ namespace BTCPayServer.Tests
|
||||||
Assert.Single(viewerCustodianAccounts);
|
Assert.Single(viewerCustodianAccounts);
|
||||||
Assert.Equal(viewerCustodianAccounts.First().CustodianCode, custodian.Code);
|
Assert.Equal(viewerCustodianAccounts.First().CustodianCode, custodian.Code);
|
||||||
Assert.Null(viewerCustodianAccounts.First().Config);
|
Assert.Null(viewerCustodianAccounts.First().Config);
|
||||||
|
|
||||||
|
// Wrong store ID
|
||||||
|
await AssertApiError(403, "missing-permission", async () => await adminClient.GetCustodianAccounts("WRONG-STORE-ID"));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Try to fetch 1
|
// Try to fetch 1 custodian account
|
||||||
// Admin
|
// Admin
|
||||||
var singleAdminCustodianAccount = await adminClient.GetCustodianAccount(storeId, accountId);
|
var singleAdminCustodianAccount = await adminClient.GetCustodianAccount(storeId, accountId);
|
||||||
Assert.NotNull(singleAdminCustodianAccount);
|
Assert.NotNull(singleAdminCustodianAccount);
|
||||||
Assert.Equal(singleAdminCustodianAccount.CustodianCode, custodian.Code);
|
Assert.Equal(singleAdminCustodianAccount.CustodianCode, custodian.Code);
|
||||||
|
|
||||||
|
// Wrong store ID
|
||||||
|
await AssertApiError(403, "missing-permission",async () => await adminClient.GetCustodianAccount("WRONG-STORE-ID", accountId));
|
||||||
|
|
||||||
|
// Wrong account ID
|
||||||
|
await AssertApiError(404, "custodian-account-not-found",async () => await adminClient.GetCustodianAccount(storeId, "WRONG-ACCOUNT-ID"));
|
||||||
|
|
||||||
// Manager can see, including config
|
// Manager can see, including config
|
||||||
var singleManagerCustodianAccount = await managerClient.GetCustodianAccount(storeId, accountId);
|
var singleManagerCustodianAccount = await managerClient.GetCustodianAccount(storeId, accountId);
|
||||||
|
@ -2789,8 +2792,6 @@ namespace BTCPayServer.Tests
|
||||||
// Load a custodian, we use the first one we find.
|
// Load a custodian, we use the first one we find.
|
||||||
var custodians = tester.PayTester.GetService<IEnumerable<ICustodian>>();
|
var custodians = tester.PayTester.GetService<IEnumerable<ICustodian>>();
|
||||||
var mockCustodian = custodians.First(c => c.Code == "mock");
|
var mockCustodian = custodians.First(c => c.Code == "mock");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Create custodian account
|
// Create custodian account
|
||||||
var createCustodianAccountRequest = new CreateCustodianAccountRequest();
|
var createCustodianAccountRequest = new CreateCustodianAccountRequest();
|
||||||
|
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Abstractions.Custodians;
|
using BTCPayServer.Abstractions.Custodians;
|
||||||
|
using BTCPayServer.Abstractions.Form;
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
@ -50,6 +51,11 @@ public class MockCustodian : ICustodian, ICanDeposit, ICanTrade, ICanWithdraw
|
||||||
return Task.FromResult(r);
|
return Task.FromResult(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<Form> GetConfigForm(JObject config, string locale, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
public Task<DepositAddressData> GetDepositAddressAsync(string paymentMethod, JObject config, CancellationToken cancellationToken)
|
public Task<DepositAddressData> GetDepositAddressAsync(string paymentMethod, JObject config, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (paymentMethod.Equals(DepositPaymentMethod))
|
if (paymentMethod.Equals(DepositPaymentMethod))
|
||||||
|
|
|
@ -147,6 +147,7 @@
|
||||||
<ProjectReference Include="..\BTCPayServer.Data\BTCPayServer.Data.csproj" />
|
<ProjectReference Include="..\BTCPayServer.Data\BTCPayServer.Data.csproj" />
|
||||||
<ProjectReference Include="..\BTCPayServer.Rating\BTCPayServer.Rating.csproj" />
|
<ProjectReference Include="..\BTCPayServer.Rating\BTCPayServer.Rating.csproj" />
|
||||||
<ProjectReference Include="..\BTCPayServer.Common\BTCPayServer.Common.csproj" />
|
<ProjectReference Include="..\BTCPayServer.Common\BTCPayServer.Common.csproj" />
|
||||||
|
<ProjectReference Include="..\Plugins\BTCPayServer.Plugins.Custodians.FakeCustodian\BTCPayServer.Plugins.Custodians.FakeCustodian.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
|
@ -8,7 +8,14 @@
|
||||||
@using BTCPayServer.Abstractions.Extensions
|
@using BTCPayServer.Abstractions.Extensions
|
||||||
@using BTCPayServer.Abstractions.Contracts
|
@using BTCPayServer.Abstractions.Contracts
|
||||||
@using BTCPayServer.Client
|
@using BTCPayServer.Client
|
||||||
|
@using BTCPayServer.Components.Icon
|
||||||
|
@using BTCPayServer.Components.ThemeSwitch
|
||||||
|
@using BTCPayServer.Components.UIExtensionPoint
|
||||||
|
@using BTCPayServer.Security
|
||||||
@using BTCPayServer.Services
|
@using BTCPayServer.Services
|
||||||
|
@using BTCPayServer.TagHelpers
|
||||||
|
@using BTCPayServer.Views.CustodianAccounts
|
||||||
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
@inject BTCPayServer.Services.BTCPayServerEnvironment Env
|
@inject BTCPayServer.Services.BTCPayServerEnvironment Env
|
||||||
@inject SignInManager<ApplicationUser> SignInManager
|
@inject SignInManager<ApplicationUser> SignInManager
|
||||||
@inject PoliciesSettings PoliciesSettings
|
@inject PoliciesSettings PoliciesSettings
|
||||||
|
@ -92,6 +99,30 @@
|
||||||
|
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (PoliciesSettings.Experimental)
|
||||||
|
{
|
||||||
|
@foreach (var custodianAccount in Model.CustodianAccounts)
|
||||||
|
{
|
||||||
|
<li class="nav-item">
|
||||||
|
<a asp-area="" asp-controller="UICustodianAccounts" asp-action="ViewCustodianAccount" asp-route-storeId="@custodianAccount.StoreId" asp-route-accountId="@custodianAccount.Id" class="nav-link js-scroll-trigger @ViewData.IsActivePage(CustodianAccountsNavPages.View, custodianAccount.Id)" id="@($"StoreNav-CustodianAccount-{custodianAccount.Id}")">
|
||||||
|
<!--
|
||||||
|
TODO which icon should we use?
|
||||||
|
-->
|
||||||
|
<span>@custodianAccount.Name</span>
|
||||||
|
<span class="badge bg-warning ms-1" style="font-size:10px;">Experimental</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a asp-area="" asp-controller="UICustodianAccounts" asp-action="CreateCustodianAccount" asp-route-storeId="@Model.Store.Id" class="nav-link js-scroll-trigger @ViewData.IsActivePage(CustodianAccountsNavPages.Create)" id="StoreNav-CreateApp">
|
||||||
|
<vc:icon symbol="new"/>
|
||||||
|
<span>Add Custodian</span>
|
||||||
|
<span class="badge bg-warning ms-1" style="font-size:10px;">Experimental</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -6,7 +6,9 @@ using BTCPayServer.Data;
|
||||||
using BTCPayServer.Models.StoreViewModels;
|
using BTCPayServer.Models.StoreViewModels;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
using BTCPayServer.Payments.Lightning;
|
using BTCPayServer.Payments.Lightning;
|
||||||
|
using BTCPayServer.Services;
|
||||||
using BTCPayServer.Services.Apps;
|
using BTCPayServer.Services.Apps;
|
||||||
|
using BTCPayServer.Services.Custodian.Client;
|
||||||
using BTCPayServer.Services.Invoices;
|
using BTCPayServer.Services.Invoices;
|
||||||
using BTCPayServer.Services.Stores;
|
using BTCPayServer.Services.Stores;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
@ -25,6 +27,7 @@ namespace BTCPayServer.Components.MainNav
|
||||||
private readonly BTCPayNetworkProvider _networkProvider;
|
private readonly BTCPayNetworkProvider _networkProvider;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary;
|
private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary;
|
||||||
|
private readonly CustodianAccountRepository _custodianAccountRepository;
|
||||||
|
|
||||||
public MainNav(
|
public MainNav(
|
||||||
AppService appService,
|
AppService appService,
|
||||||
|
@ -32,7 +35,9 @@ namespace BTCPayServer.Components.MainNav
|
||||||
UIStoresController storesController,
|
UIStoresController storesController,
|
||||||
BTCPayNetworkProvider networkProvider,
|
BTCPayNetworkProvider networkProvider,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
PaymentMethodHandlerDictionary paymentMethodHandlerDictionary)
|
PaymentMethodHandlerDictionary paymentMethodHandlerDictionary,
|
||||||
|
CustodianAccountRepository custodianAccountRepository,
|
||||||
|
PoliciesSettings policiesSettings)
|
||||||
{
|
{
|
||||||
_storeRepo = storeRepo;
|
_storeRepo = storeRepo;
|
||||||
_appService = appService;
|
_appService = appService;
|
||||||
|
@ -40,6 +45,8 @@ namespace BTCPayServer.Components.MainNav
|
||||||
_networkProvider = networkProvider;
|
_networkProvider = networkProvider;
|
||||||
_storesController = storesController;
|
_storesController = storesController;
|
||||||
_paymentMethodHandlerDictionary = paymentMethodHandlerDictionary;
|
_paymentMethodHandlerDictionary = paymentMethodHandlerDictionary;
|
||||||
|
_custodianAccountRepository = custodianAccountRepository;
|
||||||
|
PoliciesSettings = policiesSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IViewComponentResult> InvokeAsync()
|
public async Task<IViewComponentResult> InvokeAsync()
|
||||||
|
@ -68,11 +75,21 @@ namespace BTCPayServer.Components.MainNav
|
||||||
AppType = a.AppType,
|
AppType = a.AppType,
|
||||||
IsOwner = a.IsOwner
|
IsOwner = a.IsOwner
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
|
|
||||||
|
if (PoliciesSettings.Experimental)
|
||||||
|
{
|
||||||
|
// Custodian Accounts
|
||||||
|
var custodianAccounts = await _custodianAccountRepository.FindByStoreId(store.Id);
|
||||||
|
vm.CustodianAccounts = custodianAccounts;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return View(vm);
|
return View(vm);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string UserId => _userManager.GetUserId(HttpContext.User);
|
private string UserId => _userManager.GetUserId(HttpContext.User);
|
||||||
|
|
||||||
|
public PoliciesSettings PoliciesSettings { get; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ namespace BTCPayServer.Components.MainNav
|
||||||
public List<StoreDerivationScheme> DerivationSchemes { get; set; }
|
public List<StoreDerivationScheme> DerivationSchemes { get; set; }
|
||||||
public List<StoreLightningNode> LightningNodes { get; set; }
|
public List<StoreLightningNode> LightningNodes { get; set; }
|
||||||
public List<StoreApp> Apps { get; set; }
|
public List<StoreApp> Apps { get; set; }
|
||||||
|
public CustodianAccountData[] CustodianAccounts { get; set; }
|
||||||
public bool AltcoinsBuild { get; set; }
|
public bool AltcoinsBuild { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
||||||
using BTCPayServer.Abstractions.Constants;
|
using BTCPayServer.Abstractions.Constants;
|
||||||
using BTCPayServer.Abstractions.Custodians;
|
using BTCPayServer.Abstractions.Custodians;
|
||||||
using BTCPayServer.Abstractions.Extensions;
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
|
using BTCPayServer.Abstractions.Form;
|
||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
|
@ -18,6 +19,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Cors;
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
using CustodianAccountData = BTCPayServer.Data.CustodianAccountData;
|
using CustodianAccountData = BTCPayServer.Data.CustodianAccountData;
|
||||||
using CustodianAccountDataClient = BTCPayServer.Client.Models.CustodianAccountData;
|
using CustodianAccountDataClient = BTCPayServer.Client.Models.CustodianAccountData;
|
||||||
|
|
||||||
|
@ -80,10 +82,51 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||||
public async Task<IActionResult> ViewCustodianAccount(string storeId, string accountId,
|
public async Task<IActionResult> ViewCustodianAccount(string storeId, string accountId,
|
||||||
[FromQuery] bool assetBalances = false, CancellationToken cancellationToken = default)
|
[FromQuery] bool assetBalances = false, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var custodianAccountData = await GetCustodian(storeId, accountId);
|
var custodianAccountData = await GetCustodianAccount(storeId, accountId);
|
||||||
|
if (custodianAccountData == null)
|
||||||
|
{
|
||||||
|
return this.CreateAPIError(404, "custodian-account-not-found", "The custodian account was not found.");
|
||||||
|
}
|
||||||
var custodianAccount = await ToModel(custodianAccountData, assetBalances, cancellationToken);
|
var custodianAccount = await ToModel(custodianAccountData, assetBalances, cancellationToken);
|
||||||
return Ok(custodianAccount);
|
return Ok(custodianAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [HttpGet("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/config")]
|
||||||
|
// [Authorize(Policy = Policies.CanManageCustodianAccounts,
|
||||||
|
// AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||||
|
// public async Task<IActionResult> FetchCustodianAccountConfigForm(string storeId, string accountId,
|
||||||
|
// [FromQuery] string locale = "en-US", CancellationToken cancellationToken = default)
|
||||||
|
// {
|
||||||
|
// // TODO this endpoint needs tests
|
||||||
|
// var custodianAccountData = await GetCustodianAccount(storeId, accountId);
|
||||||
|
// var custodianAccount = await ToModel(custodianAccountData, false, cancellationToken);
|
||||||
|
//
|
||||||
|
// var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
|
||||||
|
// var form = await custodian.GetConfigForm(custodianAccount.Config, locale, cancellationToken);
|
||||||
|
//
|
||||||
|
// return Ok(form);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// [HttpPost("~/api/v1/stores/{storeId}/custodian-accounts/{accountId}/config")]
|
||||||
|
// [Authorize(Policy = Policies.CanManageCustodianAccounts,
|
||||||
|
// AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||||
|
// public async Task<IActionResult> PostCustodianAccountConfigForm(string storeId, string accountId, JObject values,
|
||||||
|
// [FromQuery] string locale = "en-US", CancellationToken cancellationToken = default)
|
||||||
|
// {
|
||||||
|
// // TODO this endpoint needs tests
|
||||||
|
// var custodianAccountData = await GetCustodianAccount(storeId, accountId);
|
||||||
|
// var custodianAccount = await ToModel(custodianAccountData, false, cancellationToken);
|
||||||
|
//
|
||||||
|
// var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
|
||||||
|
// var form = await custodian.GetConfigForm(values, locale, cancellationToken);
|
||||||
|
//
|
||||||
|
// if (form.IsValid())
|
||||||
|
// {
|
||||||
|
// // TODO save the data to the config so it is persisted
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return Ok(form);
|
||||||
|
// }
|
||||||
|
|
||||||
private async Task<bool> CanSeeCustodianAccountConfig()
|
private async Task<bool> CanSeeCustodianAccountConfig()
|
||||||
{
|
{
|
||||||
|
@ -138,7 +181,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||||
{
|
{
|
||||||
request ??= new CreateCustodianAccountRequest();
|
request ??= new CreateCustodianAccountRequest();
|
||||||
|
|
||||||
var custodianAccount = await GetCustodian(storeId, accountId);
|
var custodianAccount = await GetCustodianAccount(storeId, accountId);
|
||||||
var custodian = GetCustodianByCode(request.CustodianCode);
|
var custodian = GetCustodianByCode(request.CustodianCode);
|
||||||
|
|
||||||
// TODO If storeId is not valid, we get a foreign key SQL error. Is this okay or do we want to check the storeId first?
|
// TODO If storeId is not valid, we get a foreign key SQL error. Is this okay or do we want to check the storeId first?
|
||||||
|
@ -171,7 +214,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||||
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||||
public async Task<IActionResult> GetDepositAddress(string storeId, string accountId, string paymentMethod, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetDepositAddress(string storeId, string accountId, string paymentMethod, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var custodianAccount = await GetCustodian(storeId, accountId);
|
var custodianAccount = await GetCustodianAccount(storeId, accountId);
|
||||||
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
|
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
|
||||||
var config = custodianAccount.GetBlob();
|
var config = custodianAccount.GetBlob();
|
||||||
|
|
||||||
|
@ -198,7 +241,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||||
$"Please use 'BTC' instead of 'SATS'.");
|
$"Please use 'BTC' instead of 'SATS'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var custodianAccount = await GetCustodian(storeId, accountId);
|
var custodianAccount = await GetCustodianAccount(storeId, accountId);
|
||||||
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
|
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
|
||||||
|
|
||||||
if (custodian is ICanTrade tradableCustodian)
|
if (custodian is ICanTrade tradableCustodian)
|
||||||
|
@ -248,7 +291,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||||
$"Please use 'BTC' instead of 'SATS'.");
|
$"Please use 'BTC' instead of 'SATS'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var custodianAccount = await GetCustodian(storeId, accountId);
|
var custodianAccount = await GetCustodianAccount(storeId, accountId);
|
||||||
|
|
||||||
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
|
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
|
||||||
|
|
||||||
|
@ -267,7 +310,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||||
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||||
public async Task<IActionResult> GetTradeInfo(string storeId, string accountId, string tradeId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetTradeInfo(string storeId, string accountId, string tradeId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var custodianAccount = await GetCustodian(storeId, accountId);
|
var custodianAccount = await GetCustodianAccount(storeId, accountId);
|
||||||
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
|
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
|
||||||
|
|
||||||
if (custodian is ICanTrade tradableCustodian)
|
if (custodian is ICanTrade tradableCustodian)
|
||||||
|
@ -292,7 +335,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||||
public async Task<IActionResult> CreateWithdrawal(string storeId, string accountId,
|
public async Task<IActionResult> CreateWithdrawal(string storeId, string accountId,
|
||||||
WithdrawRequestData request, CancellationToken cancellationToken = default)
|
WithdrawRequestData request, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var custodianAccount = await GetCustodian(storeId, accountId);
|
var custodianAccount = await GetCustodianAccount(storeId, accountId);
|
||||||
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
|
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
|
||||||
|
|
||||||
if (custodian is ICanWithdraw withdrawableCustodian)
|
if (custodian is ICanWithdraw withdrawableCustodian)
|
||||||
|
@ -309,7 +352,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async Task<CustodianAccountData> GetCustodian(string storeId, string accountId)
|
async Task<CustodianAccountData> GetCustodianAccount(string storeId, string accountId)
|
||||||
{
|
{
|
||||||
var cust = await _custodianAccountRepository.FindById(storeId, accountId);
|
var cust = await _custodianAccountRepository.FindById(storeId, accountId);
|
||||||
if (cust is null)
|
if (cust is null)
|
||||||
|
@ -335,7 +378,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||||
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||||
public async Task<IActionResult> GetWithdrawalInfo(string storeId, string accountId, string paymentMethod, string withdrawalId, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> GetWithdrawalInfo(string storeId, string accountId, string paymentMethod, string withdrawalId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var custodianAccount = await GetCustodian(storeId, accountId);
|
var custodianAccount = await GetCustodianAccount(storeId, accountId);
|
||||||
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
|
var custodian = GetCustodianByCode(custodianAccount.CustodianCode);
|
||||||
|
|
||||||
if (custodian is ICanWithdraw withdrawableCustodian)
|
if (custodian is ICanWithdraw withdrawableCustodian)
|
||||||
|
|
|
@ -75,7 +75,7 @@ namespace BTCPayServer
|
||||||
CreationStore.Remove(userId, out _);
|
CreationStore.Remove(userId, out _);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
340
BTCPayServer/Controllers/UICustodianAccountsController.cs
Normal file
340
BTCPayServer/Controllers/UICustodianAccountsController.cs
Normal file
|
@ -0,0 +1,340 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions.Constants;
|
||||||
|
using BTCPayServer.Abstractions.Custodians;
|
||||||
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
|
using BTCPayServer.Abstractions.Form;
|
||||||
|
using BTCPayServer.Client;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Filters;
|
||||||
|
using BTCPayServer.Models.CustodianAccountViewModels;
|
||||||
|
using BTCPayServer.Services.Custodian.Client;
|
||||||
|
using BTCPayServer.Services.Rates;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using CustodianAccountData = BTCPayServer.Data.CustodianAccountData;
|
||||||
|
using StoreData = BTCPayServer.Data.StoreData;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Controllers
|
||||||
|
{
|
||||||
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
[AutoValidateAntiforgeryToken]
|
||||||
|
[ExperimentalRouteAttribute]
|
||||||
|
public class UICustodianAccountsController : Controller
|
||||||
|
{
|
||||||
|
public UICustodianAccountsController(
|
||||||
|
CurrencyNameTable currencyNameTable,
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
CustodianAccountRepository custodianAccountRepository,
|
||||||
|
IEnumerable<ICustodian> custodianRegistry
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_currencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable));
|
||||||
|
_userManager = userManager;
|
||||||
|
_custodianAccountRepository = custodianAccountRepository;
|
||||||
|
_custodianRegistry = custodianRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly IEnumerable<ICustodian> _custodianRegistry;
|
||||||
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
private readonly CustodianAccountRepository _custodianAccountRepository;
|
||||||
|
private readonly CurrencyNameTable _currencyNameTable;
|
||||||
|
|
||||||
|
public string CreatedCustodianAccountId { get; set; }
|
||||||
|
|
||||||
|
[HttpGet("/stores/{storeId}/custodian-accounts/{accountId}")]
|
||||||
|
public async Task<IActionResult> ViewCustodianAccount(string storeId, string accountId)
|
||||||
|
{
|
||||||
|
var vm = new ViewCustodianAccountViewModel();
|
||||||
|
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
|
||||||
|
|
||||||
|
if (custodianAccount == null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var custodian = _custodianRegistry.GetCustodianByCode(custodianAccount.CustodianCode);
|
||||||
|
if (custodian == null)
|
||||||
|
{
|
||||||
|
// TODO The custodian account is broken. The custodian is no longer available. Maybe delete the custodian account?
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
vm.Custodian = custodian;
|
||||||
|
vm.CustodianAccount = custodianAccount;
|
||||||
|
var store = GetCurrentStore();
|
||||||
|
var storeBlob = BTCPayServer.Data.StoreDataExtensions.GetStoreBlob(store);
|
||||||
|
var defaultCurrency = storeBlob.DefaultCurrency;
|
||||||
|
vm.DefaultCurrency = defaultCurrency;
|
||||||
|
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var assetBalances = new Dictionary<string, AssetBalanceInfo>();
|
||||||
|
var assetBalancesData =
|
||||||
|
await custodian.GetAssetBalancesAsync(custodianAccount.GetBlob(), cancellationToken: default);
|
||||||
|
|
||||||
|
foreach (var pair in assetBalancesData)
|
||||||
|
{
|
||||||
|
var asset = pair.Key;
|
||||||
|
|
||||||
|
assetBalances.Add(asset,
|
||||||
|
new AssetBalanceInfo { Asset = asset, Qty = pair.Value }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (custodian is ICanTrade tradingCustodian)
|
||||||
|
{
|
||||||
|
var config = custodianAccount.GetBlob();
|
||||||
|
var tradableAssetPairs = tradingCustodian.GetTradableAssetPairs();
|
||||||
|
|
||||||
|
foreach (var pair in assetBalances)
|
||||||
|
{
|
||||||
|
var asset = pair.Key;
|
||||||
|
var assetBalance = assetBalances[asset];
|
||||||
|
|
||||||
|
if (asset.Equals(defaultCurrency))
|
||||||
|
{
|
||||||
|
assetBalance.FormattedFiatValue = _currencyNameTable.DisplayFormatCurrency(pair.Value.Qty, defaultCurrency);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var quote = await tradingCustodian.GetQuoteForAssetAsync(defaultCurrency, asset,
|
||||||
|
config, default);
|
||||||
|
assetBalance.Bid = quote.Bid;
|
||||||
|
assetBalance.Ask = quote.Ask;
|
||||||
|
assetBalance.FiatAsset = defaultCurrency;
|
||||||
|
assetBalance.FormattedBid = _currencyNameTable.DisplayFormatCurrency(quote.Bid, quote.FromAsset);
|
||||||
|
assetBalance.FormattedAsk = _currencyNameTable.DisplayFormatCurrency(quote.Ask, quote.FromAsset);
|
||||||
|
assetBalance.FormattedFiatValue = _currencyNameTable.DisplayFormatCurrency(pair.Value.Qty * quote.Bid, pair.Value.FiatAsset);
|
||||||
|
assetBalance.TradableAssetPairs = tradableAssetPairs.Where(o => o.AssetBought == asset || o.AssetSold == asset);
|
||||||
|
}
|
||||||
|
catch (WrongTradingPairException e)
|
||||||
|
{
|
||||||
|
// Cannot trade this asset, just ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (custodian is ICanWithdraw withdrawableCustodian)
|
||||||
|
{
|
||||||
|
var withdrawableePaymentMethods = withdrawableCustodian.GetWithdrawablePaymentMethods();
|
||||||
|
foreach (var withdrawableePaymentMethod in withdrawableePaymentMethods)
|
||||||
|
{
|
||||||
|
var withdrawableAsset = withdrawableePaymentMethod.Split("-")[0];
|
||||||
|
if (assetBalances.ContainsKey(withdrawableAsset))
|
||||||
|
{
|
||||||
|
var assetBalance = assetBalances[withdrawableAsset];
|
||||||
|
assetBalance.CanWithdraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (custodian is ICanDeposit depositableCustodian)
|
||||||
|
{
|
||||||
|
var depositablePaymentMethods = depositableCustodian.GetDepositablePaymentMethods();
|
||||||
|
foreach (var depositablePaymentMethod in depositablePaymentMethods)
|
||||||
|
{
|
||||||
|
var depositableAsset = depositablePaymentMethod.Split("-")[0];
|
||||||
|
if (assetBalances.ContainsKey(depositableAsset))
|
||||||
|
{
|
||||||
|
var assetBalance = assetBalances[depositableAsset];
|
||||||
|
assetBalance.CanDeposit = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vm.AssetBalances = assetBalances;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
vm.GetAssetBalanceException = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("/stores/{storeId}/custodian-accounts/{accountId}/edit")]
|
||||||
|
public async Task<IActionResult> EditCustodianAccount(string storeId, string accountId)
|
||||||
|
{
|
||||||
|
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
|
||||||
|
if (custodianAccount == null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var custodian = _custodianRegistry.GetCustodianByCode(custodianAccount.CustodianCode);
|
||||||
|
if (custodian == null)
|
||||||
|
{
|
||||||
|
// TODO The custodian account is broken. The custodian is no longer available. Maybe delete the custodian account?
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
var configForm = await custodian.GetConfigForm(custodianAccount.GetBlob(), "en-US");
|
||||||
|
|
||||||
|
var vm = new EditCustodianAccountViewModel();
|
||||||
|
vm.CustodianAccount = custodianAccount;
|
||||||
|
vm.ConfigForm = configForm;
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("/stores/{storeId}/custodian-accounts/{accountId}/edit")]
|
||||||
|
public async Task<IActionResult> EditCustodianAccount(string storeId, string accountId,
|
||||||
|
EditCustodianAccountViewModel vm)
|
||||||
|
{
|
||||||
|
// The locale is not important yet, but keeping it here so we can find it easily when localization becomes a thing.
|
||||||
|
var locale = "en-US";
|
||||||
|
|
||||||
|
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
|
||||||
|
if (custodianAccount == null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var custodian = _custodianRegistry.GetCustodianByCode(custodianAccount.CustodianCode);
|
||||||
|
if (custodian == null)
|
||||||
|
{
|
||||||
|
// TODO The custodian account is broken. The custodian is no longer available. Maybe delete the custodian account?
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
var configForm = await custodian.GetConfigForm(custodianAccount.GetBlob(), locale);
|
||||||
|
|
||||||
|
var newData = new JObject();
|
||||||
|
foreach (var pair in Request.Form)
|
||||||
|
{
|
||||||
|
if ("CustodianAccount.Name".Equals(pair.Key))
|
||||||
|
{
|
||||||
|
custodianAccount.Name = pair.Value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// TODO support posted array notation, like a field called "WithdrawToAddressNamePerPaymentMethod[BTC-OnChain]". The data should be nested in the JSON.
|
||||||
|
newData.Add(pair.Key, pair.Value.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var newConfigData = RemoveUnusedFieldsFromConfig(custodianAccount.GetBlob(), newData, configForm);
|
||||||
|
var newConfigForm = await custodian.GetConfigForm(newConfigData, locale);
|
||||||
|
|
||||||
|
if (newConfigForm.IsValid())
|
||||||
|
{
|
||||||
|
custodianAccount.SetBlob(newConfigData);
|
||||||
|
custodianAccount = await _custodianAccountRepository.CreateOrUpdate(custodianAccount);
|
||||||
|
|
||||||
|
return RedirectToAction(nameof(ViewCustodianAccount),
|
||||||
|
new { storeId = custodianAccount.StoreId, accountId = custodianAccount.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form not valid: The user must fix the errors before we can save
|
||||||
|
vm.CustodianAccount = custodianAccount;
|
||||||
|
vm.ConfigForm = newConfigForm;
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[HttpGet("/stores/{storeId}/custodian-accounts/create")]
|
||||||
|
public IActionResult CreateCustodianAccount(string storeId)
|
||||||
|
{
|
||||||
|
var vm = new CreateCustodianAccountViewModel();
|
||||||
|
vm.StoreId = storeId;
|
||||||
|
vm.SetCustodianRegistry(_custodianRegistry);
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("/stores/{storeId}/custodian-accounts/create")]
|
||||||
|
public async Task<IActionResult> CreateCustodianAccount(string storeId, CreateCustodianAccountViewModel vm)
|
||||||
|
{
|
||||||
|
var store = GetCurrentStore();
|
||||||
|
vm.StoreId = store.Id;
|
||||||
|
vm.SetCustodianRegistry(_custodianRegistry);
|
||||||
|
|
||||||
|
var custodian = _custodianRegistry.GetCustodianByCode(vm.SelectedCustodian);
|
||||||
|
if (custodian == null)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(vm.SelectedCustodian), "Invalid Custodian");
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
if (string.IsNullOrEmpty(vm.Name))
|
||||||
|
{
|
||||||
|
vm.Name = custodian.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
var custodianAccountData = new CustodianAccountData { CustodianCode = vm.SelectedCustodian, StoreId = vm.StoreId, Name = custodian.Name };
|
||||||
|
|
||||||
|
|
||||||
|
var configData = new JObject();
|
||||||
|
foreach (var pair in Request.Form)
|
||||||
|
{
|
||||||
|
configData.Add(pair.Key, pair.Value.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
var configForm = await custodian.GetConfigForm(configData, "en-US");
|
||||||
|
if (configForm.IsValid())
|
||||||
|
{
|
||||||
|
// configForm.removeUnusedKeys();
|
||||||
|
custodianAccountData.SetBlob(configData);
|
||||||
|
custodianAccountData = await _custodianAccountRepository.CreateOrUpdate(custodianAccountData);
|
||||||
|
TempData[WellKnownTempData.SuccessMessage] = "Custodian account successfully created";
|
||||||
|
CreatedCustodianAccountId = custodianAccountData.Id;
|
||||||
|
|
||||||
|
return RedirectToAction(nameof(ViewCustodianAccount),
|
||||||
|
new { storeId = custodianAccountData.StoreId, accountId = custodianAccountData.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask for more data
|
||||||
|
vm.ConfigForm = configForm;
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[HttpGet("/stores/{storeId}/custodian-accounts/{accountId}/delete")]
|
||||||
|
public async Task<IActionResult> DeleteCustodianAccount(string storeId, string accountId)
|
||||||
|
{
|
||||||
|
var custodianAccount = await _custodianAccountRepository.FindById(storeId, accountId);
|
||||||
|
if (custodianAccount == null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDeleted = await _custodianAccountRepository.Remove(custodianAccount.Id, custodianAccount.StoreId);
|
||||||
|
if (isDeleted)
|
||||||
|
{
|
||||||
|
TempData[WellKnownTempData.SuccessMessage] = "Custodian account deleted";
|
||||||
|
return RedirectToAction("Dashboard", "UIStores", new { storeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
TempData[WellKnownTempData.ErrorMessage] = "Could not delete custodian account";
|
||||||
|
return RedirectToAction(nameof(ViewCustodianAccount),
|
||||||
|
new { storeId = custodianAccount.StoreId, accountId = custodianAccount.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The JObject may contain too much data because we used ALL post values and this may be more than we needed.
|
||||||
|
// Because we don't know the form fields beforehand, we will filter out the superfluous data afterwards.
|
||||||
|
// We will keep all the old keys + merge the new keys as per the current form.
|
||||||
|
// Since the form can differ by circumstances, we will never remove any keys that were previously stored. We just limit what we add.
|
||||||
|
private JObject RemoveUnusedFieldsFromConfig(JObject storedData, JObject newData, Form form)
|
||||||
|
{
|
||||||
|
JObject filteredData = new JObject();
|
||||||
|
var storedKeys = new List<string>();
|
||||||
|
foreach (var item in storedData)
|
||||||
|
{
|
||||||
|
storedKeys.Add(item.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
var formKeys = form.GetAllNames();
|
||||||
|
|
||||||
|
foreach (var item in newData)
|
||||||
|
{
|
||||||
|
if (storedKeys.Contains(item.Key) || formKeys.Contains(item.Key))
|
||||||
|
{
|
||||||
|
filteredData[item.Key] = item.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private StoreData GetCurrentStore() => HttpContext.GetStoreData();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,3 @@
|
||||||
using System;
|
|
||||||
using NBXplorer;
|
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace BTCPayServer.Data;
|
namespace BTCPayServer.Data;
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Models.CustodianAccountViewModels;
|
||||||
|
|
||||||
|
public class AssetBalanceInfo
|
||||||
|
{
|
||||||
|
|
||||||
|
public string Asset { get; set; }
|
||||||
|
public decimal? Bid { get; set; }
|
||||||
|
public decimal? Ask { get; set; }
|
||||||
|
public decimal Qty { get; set; }
|
||||||
|
public string FiatAsset { get; set; }
|
||||||
|
public string FormattedFiatValue { get; set; }
|
||||||
|
public IEnumerable<AssetPairData> TradableAssetPairs { get; set; }
|
||||||
|
public bool CanWithdraw { get; set; }
|
||||||
|
public bool CanDeposit { get; set; }
|
||||||
|
public string FormattedBid { get; set; }
|
||||||
|
public string FormattedAsk { get; set; }
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Linq;
|
||||||
|
using BTCPayServer.Abstractions.Custodians;
|
||||||
|
using BTCPayServer.Abstractions.Form;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Models.CustodianAccountViewModels
|
||||||
|
{
|
||||||
|
public class CreateCustodianAccountViewModel
|
||||||
|
{
|
||||||
|
|
||||||
|
public void SetCustodianRegistry(IEnumerable<ICustodian> custodianRegistry)
|
||||||
|
{
|
||||||
|
var choices = custodianRegistry.Select(o => new Format
|
||||||
|
{
|
||||||
|
Name = o.Name,
|
||||||
|
Value = o.Code
|
||||||
|
}).ToArray();
|
||||||
|
var chosen = choices.FirstOrDefault();
|
||||||
|
Custodians = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Name), chosen);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Format
|
||||||
|
{
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string Value { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(50)]
|
||||||
|
[MinLength(1)]
|
||||||
|
[Display(Name = "Name")]
|
||||||
|
public string Name { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Store")]
|
||||||
|
public string StoreId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Custodian")]
|
||||||
|
public string SelectedCustodian { get; set; }
|
||||||
|
//
|
||||||
|
public SelectList Custodians { get; set; }
|
||||||
|
|
||||||
|
public Form ConfigForm { get; set; }
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
using BTCPayServer.Abstractions.Form;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Models.CustodianAccountViewModels
|
||||||
|
{
|
||||||
|
public class EditCustodianAccountViewModel
|
||||||
|
{
|
||||||
|
|
||||||
|
public CustodianAccountData CustodianAccount { get; set; }
|
||||||
|
public Form ConfigForm { get; set; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using BTCPayServer.Abstractions.Custodians;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Models.CustodianAccountViewModels
|
||||||
|
{
|
||||||
|
public class ViewCustodianAccountViewModel
|
||||||
|
{
|
||||||
|
public ICustodian Custodian { get; set; }
|
||||||
|
public CustodianAccountData CustodianAccount { get; set; }
|
||||||
|
public Dictionary<string,AssetBalanceInfo> AssetBalances { get; set; }
|
||||||
|
public Exception GetAssetBalanceException { get; set; }
|
||||||
|
public string DefaultCurrency { get; set; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -52,7 +52,6 @@ namespace BTCPayServer.Services.Custodian.Client
|
||||||
await using var context = _contextFactory.CreateContext();
|
await using var context = _contextFactory.CreateContext();
|
||||||
IQueryable<CustodianAccountData> query = context.CustodianAccount
|
IQueryable<CustodianAccountData> query = context.CustodianAccount
|
||||||
.Where(ca => ca.StoreId == storeId);
|
.Where(ca => ca.StoreId == storeId);
|
||||||
//.SelectMany(c => c.StoreData.Invoices);
|
|
||||||
|
|
||||||
var data = await query.ToArrayAsync( cancellationToken).ConfigureAwait(false);
|
var data = await query.ToArrayAsync( cancellationToken).ConfigureAwait(false);
|
||||||
return data;
|
return data;
|
||||||
|
|
34
BTCPayServer/Views/Shared/_Form.cshtml
Normal file
34
BTCPayServer/Views/Shared/_Form.cshtml
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
@model BTCPayServer.Abstractions.Form.Form
|
||||||
|
|
||||||
|
@foreach (var fieldset in Model.Fieldsets)
|
||||||
|
{
|
||||||
|
<fieldset>
|
||||||
|
<legend>@fieldset.Label</legend>
|
||||||
|
@foreach (var field in fieldset.Fields)
|
||||||
|
{
|
||||||
|
@if ("text".Equals(field.Type))
|
||||||
|
{
|
||||||
|
<div class="form-group">
|
||||||
|
@if (field.Required)
|
||||||
|
{
|
||||||
|
<label class="form-label" for="@field.Name" data-required>
|
||||||
|
@field.Label
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<label class="form-label" for="@field.Name">
|
||||||
|
@field.Label
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
|
||||||
|
<input class="form-control @(@field.IsValid() ? "" : "is-invalid")" id="@field.Name" type="text" required="@field.Required" name="@field.Name" value="@field.Value" aria-describedby="HelpText@field.Name"/>
|
||||||
|
<small id="HelpText@field.Name" class="form-text text-muted">
|
||||||
|
@field.HelpText
|
||||||
|
</small>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
}
|
10
BTCPayServer/Views/Shared/_FormTopMessages.cshtml
Normal file
10
BTCPayServer/Views/Shared/_FormTopMessages.cshtml
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
@model BTCPayServer.Abstractions.Form.Form
|
||||||
|
@using ExchangeSharp
|
||||||
|
|
||||||
|
@if (Model.TopMessages.Count > 0)
|
||||||
|
{
|
||||||
|
@foreach (var alertMessage in Model.TopMessages)
|
||||||
|
{
|
||||||
|
<p class="alert alert-@alertMessage.Type.ToStringLowerInvariant()">@alertMessage.Message</p>
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
@using BTCPayServer.Views.Apps
|
||||||
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
@using BTCPayServer.Abstractions.Extensions
|
||||||
|
@model BTCPayServer.Models.CustodianAccountViewModels.CreateCustodianAccountViewModel
|
||||||
|
@{
|
||||||
|
ViewData.SetActivePage(AppsNavPages.Create, "Add a custodian account");
|
||||||
|
}
|
||||||
|
|
||||||
|
@section PageFootContent {
|
||||||
|
<partial name="_ValidationScriptsPartial" />
|
||||||
|
}
|
||||||
|
|
||||||
|
<partial name="_StatusMessage" />
|
||||||
|
|
||||||
|
<h2 class="mt-1 mb-4">@ViewData["Title"]</h2>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-8 col-xxl-constrain">
|
||||||
|
<form asp-action="CreateCustodianAccount">
|
||||||
|
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.SelectedCustodian))
|
||||||
|
{
|
||||||
|
<partial name="_FormTopMessages" model="Model.ConfigForm" />
|
||||||
|
}
|
||||||
|
else if(Model.Custodians.Count() > 0)
|
||||||
|
{
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="SelectedCustodian" class="form-label" data-required></label>
|
||||||
|
<select asp-for="SelectedCustodian" asp-items="Model.Custodians" class="form-select"></select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p>No custodians available. Install some plugins to add custodian / exchange support.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.SelectedCustodian))
|
||||||
|
{
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="SelectedCustodian" class="form-label" data-required></label>
|
||||||
|
<select disabled asp-for="SelectedCustodian" asp-items="Model.Custodians" class="form-select"></select>
|
||||||
|
<input type="hidden" asp-for="SelectedCustodian" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="Name" class="form-label" data-required></label>
|
||||||
|
<input asp-for="Name" class="form-control" required/>
|
||||||
|
<span asp-validation-for="Name" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<partial name="_Form" model="Model.ConfigForm" />
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="form-group mt-4">
|
||||||
|
<input type="submit" value="Continue" class="btn btn-primary" id="Continue" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace BTCPayServer.Views.CustodianAccounts
|
||||||
|
{
|
||||||
|
public enum CustodianAccountsNavPages
|
||||||
|
{
|
||||||
|
View, Create, Update
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
@using BTCPayServer.Views.Apps
|
||||||
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
@using BTCPayServer.Abstractions.Extensions
|
||||||
|
@model BTCPayServer.Models.CustodianAccountViewModels.EditCustodianAccountViewModel
|
||||||
|
@{
|
||||||
|
ViewData.SetActivePage(AppsNavPages.Update, "Edit custodian account");
|
||||||
|
}
|
||||||
|
|
||||||
|
@section PageFootContent {
|
||||||
|
<partial name="_ValidationScriptsPartial"/>
|
||||||
|
}
|
||||||
|
|
||||||
|
<partial name="_StatusMessage"/>
|
||||||
|
|
||||||
|
<h2 class="mt-1 mb-4">@ViewData["Title"]</h2>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-8 col-xxl-constrain">
|
||||||
|
<form asp-action="EditCustodianAccount">
|
||||||
|
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||||
|
|
||||||
|
<partial name="_FormTopMessages" model="Model.ConfigForm"/>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="CustodianAccount.Name" class="form-label" data-required></label>
|
||||||
|
<input asp-for="CustodianAccount.Name" class="form-control" required/>
|
||||||
|
<span asp-validation-for="CustodianAccount.Name" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<partial name="_Form" model="Model.ConfigForm"/>
|
||||||
|
|
||||||
|
<div class="form-group mt-4">
|
||||||
|
<input type="submit" value="Continue" class="btn btn-primary" id="Save"/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,190 @@
|
||||||
|
@using BTCPayServer.Views.Apps
|
||||||
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
@using BTCPayServer.Abstractions.Extensions
|
||||||
|
@using BTCPayServer.Abstractions.Custodians
|
||||||
|
@model BTCPayServer.Models.CustodianAccountViewModels.ViewCustodianAccountViewModel
|
||||||
|
@{
|
||||||
|
ViewData.SetActivePage(AppsNavPages.Create, "Custodian account: " + @Model?.CustodianAccount.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@section PageFootContent {
|
||||||
|
<partial name="_ValidationScriptsPartial"/>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<partial name="_StatusMessage"/>
|
||||||
|
|
||||||
|
<div class="sticky-header-setup"></div>
|
||||||
|
<div class="sticky-header d-sm-flex align-items-center justify-content-between">
|
||||||
|
<h2 class="mb-0">
|
||||||
|
@ViewData["Title"]
|
||||||
|
</h2>
|
||||||
|
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
||||||
|
<a asp-action="EditCustodianAccount" asp-route-storeId="@Model.CustodianAccount.StoreId" asp-route-accountId="@Model.CustodianAccount.Id" class="btn btn-primary mt-3 mt-sm-0" role="button" id="EditCustodianAccountConfig">
|
||||||
|
<span class="fa fa-gear"></span> Configure
|
||||||
|
</a>
|
||||||
|
<a asp-action="DeleteCustodianAccount" asp-route-storeId="@Model.CustodianAccount.StoreId" asp-route-accountId="@Model.CustodianAccount.Id" class="btn btn-danger mt-3 mt-sm-0" role="button" id="DeleteCustodianAccountConfig">
|
||||||
|
<span class="fa fa-trash"></span> Delete
|
||||||
|
</a>
|
||||||
|
<!--
|
||||||
|
<button type="submit" class="btn btn-primary order-sm-1" id="SaveSettings">Save</button>
|
||||||
|
<a class="btn btn-secondary" id="ViewApp" target="app_" href="/apps/MQ2sCVsmQ95JBZ4aZDtoSwMAnBY/pos">View</a>
|
||||||
|
-->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-12">
|
||||||
|
|
||||||
|
@if (@Model.GetAssetBalanceException != null)
|
||||||
|
{
|
||||||
|
<p class="alert alert-danger">
|
||||||
|
@Model.GetAssetBalanceException.Message
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h2>Balances</h2>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover table-responsive-md">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Asset</th>
|
||||||
|
<th class="text-end">Balance</th>
|
||||||
|
<th class="text-end">Unit Price (Bid)</th>
|
||||||
|
<th class="text-end">Unit Price (Ask)</th>
|
||||||
|
<th class="text-end">Fiat Value</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (Model.AssetBalances != null && Model.AssetBalances.Count > 0)
|
||||||
|
{
|
||||||
|
@foreach (var pair in Model.AssetBalances.OrderBy(key => key.Key))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>@pair.Key</td>
|
||||||
|
<!-- TODO format as number? How? -->
|
||||||
|
<th class="text-end">@pair.Value.Qty</th>
|
||||||
|
<th class="text-end">
|
||||||
|
@pair.Value.FormattedBid
|
||||||
|
</th>
|
||||||
|
<th class="text-end">
|
||||||
|
@pair.Value.FormattedAsk
|
||||||
|
</th>
|
||||||
|
<th class="text-end">
|
||||||
|
@(pair.Value.FormattedFiatValue)
|
||||||
|
</th>
|
||||||
|
<th class="text-end">
|
||||||
|
@if (pair.Value.TradableAssetPairs != null)
|
||||||
|
{
|
||||||
|
<a data-bs-toggle="modal" data-bs-target="#tradeModal" href="#">Trade</a>
|
||||||
|
}
|
||||||
|
@if (pair.Value.CanDeposit)
|
||||||
|
{
|
||||||
|
<a data-bs-toggle="modal" data-bs-target="#depositModal" href="#">Deposit</a>
|
||||||
|
}
|
||||||
|
@if (pair.Value.CanWithdraw)
|
||||||
|
{
|
||||||
|
<a data-bs-toggle="modal" data-bs-target="#withdrawModal" href="#">Withdraw</a>
|
||||||
|
}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (Model.GetAssetBalanceException == null)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td colspan="999" class="text-center">No assets are stored with this custodian (yet).</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td colspan="999" class="text-center">An error occured while loading assets and balances.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<h2>Features</h2>
|
||||||
|
<p>The @Model.Custodian.Name custodian supports:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Viewing asset balances</li>
|
||||||
|
@if (Model.Custodian is ICanTrade)
|
||||||
|
{
|
||||||
|
<li>Trading (Greenfield API only, for now)</li>
|
||||||
|
}
|
||||||
|
@if (Model.Custodian is ICanDeposit)
|
||||||
|
{
|
||||||
|
<li>Depositing (Greenfield API only, for now)</li>
|
||||||
|
}
|
||||||
|
@if (Model.Custodian is ICanWithdraw)
|
||||||
|
{
|
||||||
|
<li>Withdrawing (Greenfield API only, for now)</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="modal" tabindex="-1" role="dialog" id="withdrawModal">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Withdraw</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||||
|
<vc:icon symbol="close"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Withdrawals are coming soon, but if you need this today, you can use our <a rel="noopener noreferrer" href="https://docs.btcpayserver.org/API/Greenfield/v1/" target="_blank">Greenfield API "Withdraw to store wallet" endpoint</a> to execute a withdrawal.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" tabindex="-1" role="dialog" id="depositModal">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Deposit</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||||
|
<vc:icon symbol="close"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Deposits are coming soon, but if you need this today, you can use our <a rel="noopener noreferrer" href="https://docs.btcpayserver.org/API/Greenfield/v1/" target="_blank">Greenfield API "Get a deposit address for custodian" endpoint</a> to get a deposit address to send your assets to.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" tabindex="-1" role="dialog" id="tradeModal">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Trade</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||||
|
<vc:icon symbol="close"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Trades are coming soon, but if you need this today, you can use our <a rel="noopener noreferrer" href="https://docs.btcpayserver.org/API/Greenfield/v1/" target="_blank"> Greenfield API "Trade one asset for another" endpoint</a> to convert an asset to another via a market order.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,14 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>BTCPayServer.Plugins.Custodians.FakeCustodian</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
|
@ -0,0 +1,75 @@
|
||||||
|
using BTCPayServer.Abstractions.Custodians;
|
||||||
|
using BTCPayServer.Abstractions.Form;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Custodians.FakeCustodian;
|
||||||
|
|
||||||
|
public class FakeCustodian : ICustodian
|
||||||
|
{
|
||||||
|
public string Code
|
||||||
|
{
|
||||||
|
get => "fake";
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name
|
||||||
|
{
|
||||||
|
get => "Fake Exchange";
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Dictionary<string, decimal>> GetAssetBalancesAsync(JObject config, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var fakeConfig = ParseConfig(config);
|
||||||
|
var r = new Dictionary<string, decimal>()
|
||||||
|
{
|
||||||
|
{ "BTC", fakeConfig.BTCBalance },
|
||||||
|
{ "LTC", fakeConfig.LTCBalance },
|
||||||
|
{ "USD", fakeConfig.USDBalance },
|
||||||
|
{ "EUR", fakeConfig.EURBalance }
|
||||||
|
};
|
||||||
|
return Task.FromResult(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Form> GetConfigForm(JObject config, string locale, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var fakeConfig = ParseConfig(config);
|
||||||
|
|
||||||
|
var form = new Form();
|
||||||
|
var fieldset = new Fieldset();
|
||||||
|
|
||||||
|
// Maybe a decimal type field would be better?
|
||||||
|
var fakeBTCBalance = new TextField("BTC Balance", "BTCBalance", fakeConfig?.BTCBalance.ToString(), true,
|
||||||
|
"Enter the amount of BTC you want to have.");
|
||||||
|
var fakeLTCBalance = new TextField("LTC Balance", "LTCBalance", fakeConfig?.LTCBalance.ToString(), true,
|
||||||
|
"Enter the amount of LTC you want to have.");
|
||||||
|
var fakeEURBalance = new TextField("EUR Balance", "EURBalance", fakeConfig?.EURBalance.ToString(), true,
|
||||||
|
"Enter the amount of EUR you want to have.");
|
||||||
|
var fakeUSDBalance = new TextField("USD Balance", "USDBalance", fakeConfig?.USDBalance.ToString(), true,
|
||||||
|
"Enter the amount of USD you want to have.");
|
||||||
|
|
||||||
|
fieldset.Label = "Your fake balances";
|
||||||
|
fieldset.Fields.Add(fakeBTCBalance);
|
||||||
|
fieldset.Fields.Add(fakeLTCBalance);
|
||||||
|
fieldset.Fields.Add(fakeEURBalance);
|
||||||
|
fieldset.Fields.Add(fakeUSDBalance);
|
||||||
|
form.Fieldsets.Add(fieldset);
|
||||||
|
|
||||||
|
return Task.FromResult(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FakeCustodianConfig? ParseConfig(JObject config)
|
||||||
|
{
|
||||||
|
return config?.ToObject<FakeCustodianConfig>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FakeCustodianConfig
|
||||||
|
{
|
||||||
|
public decimal BTCBalance { get; set; }
|
||||||
|
public decimal LTCBalance { get; set; }
|
||||||
|
public decimal USDBalance { get; set; }
|
||||||
|
public decimal EURBalance { get; set; }
|
||||||
|
|
||||||
|
public FakeCustodianConfig()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
using BTCPayServer.Abstractions.Custodians;
|
||||||
|
using BTCPayServer.Abstractions.Models;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Custodians.FakeCustodian
|
||||||
|
{
|
||||||
|
public class FakeCustodianPlugin : BaseBTCPayServerPlugin
|
||||||
|
{
|
||||||
|
public override string Identifier { get; } = "BTCPayServer.Plugins.Custodians.Fake";
|
||||||
|
public override string Name { get; } = "Custodian: Fake";
|
||||||
|
public override string Description { get; } = "Adds a fake custodian for testing";
|
||||||
|
|
||||||
|
public override void Execute(IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<FakeCustodian>();
|
||||||
|
services.AddSingleton<ICustodian, FakeCustodian>(provider => provider.GetRequiredService<FakeCustodian>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -37,6 +37,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plugins", "Plugins", "{1FC7
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.PluginPacker", "BTCPayServer.PluginPacker\BTCPayServer.PluginPacker.csproj", "{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.PluginPacker", "BTCPayServer.PluginPacker\BTCPayServer.PluginPacker.csproj", "{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.Custodians.FakeCustodian", "Plugins\BTCPayServer.Plugins.Custodians.FakeCustodian\BTCPayServer.Plugins.Custodians.FakeCustodian.csproj", "{49E1FE45-FE71-49DF-8701-8394E974BE82}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Altcoins-Debug|Any CPU = Altcoins-Debug|Any CPU
|
Altcoins-Debug|Any CPU = Altcoins-Debug|Any CPU
|
||||||
|
|
Loading…
Add table
Reference in a new issue