Compare commits

..

50 Commits

Author SHA1 Message Date
363b60385b Renaming various properties in the Payouts API ()
* Rename Payouts Currency/OriginalCurrency

* Rename Payout Processor PayoutMethodIds

* Rename paymentMethods to payoutMethodIds

* Rename payoutMethodIds to payoutMethods
2024-09-26 11:25:45 +09:00
90635ffc4e Remove BTCPAY_EXPERIMENTALV2_CONFIRM 2024-09-25 23:11:53 +09:00
056f850268 Optimize load time of StoreRoles related pages/routes () 2024-09-25 23:10:13 +09:00
336f2d88e9 Fix flaky CanManageWallet
Clicking on Sign Transaction in the Wallet Send page, will, when a hot
wallet is setup, use PostRedirect page to redirect to the
broadcast screen. The problem was that sometimes, s.Driver.PageSource
would return this PostRedirect page rather than the broadcast page.
Waiting for an element of the broadcast page fixes this issue.
2024-09-25 21:53:15 +09:00
e16b4062b5 Fix payout processor migration 2024-09-25 18:50:49 +09:00
747dacf3b1 Consolidate migrations from alpha () 2024-09-25 18:23:10 +09:00
f00a71922f Optimize queries from payout processor at startup 2024-09-24 23:39:05 +09:00
c97c9d4ece Add SQL test for GetMonitoredInvoices 2024-09-24 22:07:02 +09:00
9d3f8672d9 Fix GetMonitoredInvoices 2024-09-24 17:21:36 +09:00
fe48cd4236 fix InvoiceRepository.GetMonitoredInvoices () 2024-09-24 15:44:51 +09:00
587d3aa612 Fix query 2024-09-24 09:52:28 +09:00
8a951940fd Remove dead property 2024-09-24 09:47:46 +09:00
b726ef8a2e Migrate PayoutProcessors's PayoutMethodId in entity migration 2024-09-24 09:43:02 +09:00
25e360e175 Allow listeners to retrieve invoices with nonActivated prompts 2024-09-24 08:43:30 +09:00
1d9ec253fb Fix migration of Invoice's payment () 2024-09-23 23:59:18 +09:00
3cf1aa00fa Payments should use composite key ()
* Payments should use composite key

* Invert PK for InvoiceAddress
2024-09-23 17:06:56 +09:00
36a5d0ee3f Fix: Monero and ZCash not tracking addresses 2024-09-22 11:13:09 +09:00
f5e5174045 Refactor: Add GetMonitoredInvoices to fetch pending invoices or those with pending payments () 2024-09-20 18:54:36 +09:00
ba2301ebfe Refactor the InvoiceAddresses table () 2024-09-19 22:15:02 +09:00
df651a2157 Fix GetInvoicesWithPendingPayments 2024-09-18 18:11:30 +09:00
2d2c1d5f2d fix: check lightning payment status () 2024-09-17 21:41:04 +09:00
0f93581ff5 Refactor confirmation count tracking () 2024-09-17 17:28:58 +09:00
397452a7fe Setting standard 150x100 size for Unbank logo 2024-09-16 16:38:04 -05:00
cd3157361a Adding Unbank as BTCPay Server Foundation Supporter () 2024-09-16 16:33:11 -05:00
29a89f185a Fix: Not able to change SpeedPolicy of a store 2024-09-13 22:59:14 +09:00
2f7a5c2967 Wallet: Generate receive address automatically ()
* Wallet: Generate receive address automatically

This circumvents landing on a blank page with only the "generate address" button and automatically generates a new address, unless the Unreserve action was used.

* Fix close button leading to same page

* Fix tests

* Remove unreserve feature

---------

Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
2024-09-13 22:03:45 +09:00
f07ed53f7e Handle password reset when SMTP isn't configured or validated ()
* Handle password reset when SMTP isn't configured or the configuration cannot be validated

* include rel in external a tag

* Simplify it

* Test fix

* Simplify a bit

* selenium test to manage users

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
2024-09-13 21:42:08 +09:00
7348a6a62f Store Branding: Apply brand color to backend as well ()
* Store Branding: Apply brand color to backend as well

Closes .

* Add adjustments for different theme scenarios

* Add description text

* Make it optional to apply the brand color to the backend

* Toggle color fixes
2024-09-13 21:39:21 +09:00
b7ba53eb60 UI: Fix for truncate center component () 2024-09-13 17:55:31 +09:00
e389d6a96b UI: Minor fix on policies page 2024-09-12 18:43:52 +02:00
0238dffc7a POS: Fix accounting for manually entered keypad amounts ()
* POS: Fix accounting for manually entered keypad amounts

For keypad orders where there are products AND manual amount entries, we didn't account for the latter.

Fixes .

* Adjust wording: "Manual entry" becomes "Custom Amount"
2024-09-12 21:36:35 +09:00
666445e8f7 Greenfield: App endpoints for sales statistics () 2024-09-12 16:17:16 +09:00
36bada8feb Uniformize Wallet API's path ()
* Uniformize Wallet API's path

* Rewrite old API path to new API

* Rename routes
2024-09-12 15:19:10 +09:00
b4946f4db1 Fix divisibility in invoice details of lightning amounts ()
* Fix divisibility in invoice details of lightning amounts

This PR will show 11 decimal in the invoice details for BTC amount
of lightning payment methods.

It also hacks around the fact that some
lightning clients don't create the requested amount of sats, which
resulted in over or under payments. (Blink not supporting msats, and
strike)

Now, In that case, a payment method fee (which can be negative) called tweak fee
will be added to the prompt.

We are also hiding this tweak fee from the user in the checkout page in
order to not disturb the UI with inconsequential fee of 0.000000001 sats.

* Only show 8 digits in checkout, even if amount is 11 digits
2024-09-12 12:43:08 +09:00
f3d485da53 Invitation process improvements ()
* Server: Make sending email optional when adding user

Closes .

* Generate custom invite token and store it in user blob

Closes btcpayserver/app/#46.

* QR code for user invite

Closes .

* Text fix
2024-09-12 12:31:57 +09:00
3342122be2 Make Role Permissions more human legible ()
Had to rename `CanModifyStoreWebhooks` to `CanModifyWebhooks` for this, but the value stayed the same, so I don't think it's a big deal.

Closes .
2024-09-12 12:29:10 +09:00
f59751853a Fx pos data additional info ()
* Resolve Additional Information from posData

* code formatting

* Minor adjustments

* Update ChromeDriver

* Revert and improve PosData partial

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
2024-09-12 12:27:02 +09:00
a60c55c6df Transactions: Improve TX ID display ()
* Transactions: Improve TX ID display

* Elastic fix

* Test and behaviour fix
2024-09-12 10:08:16 +09:00
3bad5883bb Permissions: Remove deprecated custodian account policies ()
Updates the store owner role and removes these three deprecated policies:

- `btcpay.store.cantradecustodianaccount`
- `btcpay.store.canwithdrawfromcustodianaccount`
- `btcpay.store.candeposittocustodianaccount`
2024-09-12 10:02:37 +09:00
d4c30866b7 Fix timeout issues during migration () 2024-09-10 20:11:07 +09:00
7c92ce771f Reindex Invoices table if corrupt, fix migration timeout () 2024-09-10 17:34:02 +09:00
4601359ebe Do not block the Lightning PendingPayoutListener if a client is failing 2024-09-10 17:33:36 +09:00
04b1130837 Fix tests 2024-09-09 23:39:59 +09:00
c377617b5a Fix migration issue on invalid json char 2024-09-09 23:15:53 +09:00
222e8f66df Fix entity half migrated entities for v2 2024-09-09 21:21:04 +09:00
87e2f5f414 Add migration logs 2024-09-09 19:03:07 +09:00
7de05700e9 Edit dictionary should be in JSON format () 2024-09-09 11:25:36 +09:00
841f41da2f Invoice: Improve zero amount invoice handling ()
This is for the checkout page to properly redirect paid invoices with no payment methods (e.g. free invoices with zero amount) to either the receipt page or redirect URL. Only fall back to 404 if there is neither.

Fixes .
2024-09-09 11:05:03 +09:00
73dcde7780 UI: Create Store CTA should be full-width
Closes 
2024-09-06 13:23:52 +02:00
377ff52222 Fix: Incorrect rounding for lightning amounts () 2024-09-06 18:48:20 +09:00
178 changed files with 2833 additions and 1827 deletions
BTCPayServer.Client
BTCPayServer.Common
BTCPayServer.Data
BTCPayServer.Tests
BTCPayServer
Components
Controllers
Data
Events
Extensions.cs
HostedServices
Hosting
Models
Payments
PayoutProcessors
Plugins
Program.cs
Properties
Services
UserManagerExtensions.cs
Views
wwwroot
README.md

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
@ -58,6 +59,20 @@ public partial class BTCPayServerClient
return await SendHttpRequest<CrowdfundAppData>($"api/v1/apps/crowdfund/{appId}", null, HttpMethod.Get, token);
}
public virtual async Task<AppSalesStats> GetAppSales(string appId, int numberOfDays = 7, CancellationToken token = default)
{
if (appId == null) throw new ArgumentNullException(nameof(appId));
var queryPayload = new Dictionary<string, object> { { nameof(numberOfDays), numberOfDays } };
return await SendHttpRequest<AppSalesStats>($"api/v1/apps/{appId}/sales", queryPayload, HttpMethod.Get, token);
}
public virtual async Task<List<AppItemStats>> GetAppTopItems(string appId, int offset = 0, int count = 10, CancellationToken token = default)
{
if (appId == null) throw new ArgumentNullException(nameof(appId));
var queryPayload = new Dictionary<string, object> { { nameof(offset), offset }, { nameof(count), count } };
return await SendHttpRequest<List<AppItemStats>>($"api/v1/apps/{appId}/top-items", queryPayload, HttpMethod.Get, token);
}
public virtual async Task DeleteApp(string appId, CancellationToken token = default)
{
if (appId == null) throw new ArgumentNullException(nameof(appId));

@ -15,7 +15,7 @@ public partial class BTCPayServerClient
int amount = 10,
CancellationToken token = default)
{
return await SendHttpRequest<UpdatePaymentMethodRequest, OnChainPaymentMethodPreviewResultData>($"api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/preview",
return await SendHttpRequest<UpdatePaymentMethodRequest, OnChainPaymentMethodPreviewResultData>($"api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/preview",
new Dictionary<string, object> { { "offset", offset }, { "amount", amount } },
new UpdatePaymentMethodRequest { Config = JValue.CreateString(derivationScheme) },
HttpMethod.Post, token);
@ -25,7 +25,7 @@ public partial class BTCPayServerClient
string storeId, string paymentMethodId, int offset = 0, int amount = 10,
CancellationToken token = default)
{
return await SendHttpRequest<OnChainPaymentMethodPreviewResultData>($"api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/preview",
return await SendHttpRequest<OnChainPaymentMethodPreviewResultData>($"api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/preview",
new Dictionary<string, object> { { "offset", offset }, { "amount", amount } }, HttpMethod.Get, token);
}
@ -33,7 +33,7 @@ public partial class BTCPayServerClient
string paymentMethodId, GenerateOnChainWalletRequest request,
CancellationToken token = default)
{
return await SendHttpRequest<GenerateOnChainWalletResponse>($"api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/generate", request, HttpMethod.Post, token);
return await SendHttpRequest<GenerateOnChainWalletResponse>($"api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/generate", request, HttpMethod.Post, token);
}
}

@ -15,7 +15,7 @@ public partial class BTCPayServerClient
parameters.Add("includeNeighbourData", v);
try
{
return await SendHttpRequest<OnChainWalletObjectData>($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectId.Type}/{objectId.Id}", parameters, HttpMethod.Get, token);
return await SendHttpRequest<OnChainWalletObjectData>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/objects/{objectId.Type}/{objectId.Id}", parameters, HttpMethod.Get, token);
}
catch (GreenfieldAPIException err) when (err.APIError.Code == "wallet-object-not-found")
{
@ -31,17 +31,17 @@ public partial class BTCPayServerClient
parameters.Add("ids", ids);
if (query?.IncludeNeighbourData is bool v)
parameters.Add("includeNeighbourData", v);
return await SendHttpRequest<OnChainWalletObjectData[]>($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects", parameters, HttpMethod.Get, token);
return await SendHttpRequest<OnChainWalletObjectData[]>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/objects", parameters, HttpMethod.Get, token);
}
public virtual async Task RemoveOnChainWalletObject(string storeId, string cryptoCode, OnChainWalletObjectId objectId,
CancellationToken token = default)
{
await SendHttpRequest($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectId.Type}/{objectId.Id}", null, HttpMethod.Delete, token);
await SendHttpRequest($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/objects/{objectId.Type}/{objectId.Id}", null, HttpMethod.Delete, token);
}
public virtual async Task<OnChainWalletObjectData> AddOrUpdateOnChainWalletObject(string storeId, string cryptoCode, AddOnChainWalletObjectRequest request,
CancellationToken token = default)
{
return await SendHttpRequest<OnChainWalletObjectData>($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects", request, HttpMethod.Post, token);
return await SendHttpRequest<OnChainWalletObjectData>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/objects", request, HttpMethod.Post, token);
}
public virtual async Task AddOrUpdateOnChainWalletLink(string storeId, string cryptoCode,
@ -49,7 +49,7 @@ public partial class BTCPayServerClient
AddOnChainWalletObjectLinkRequest request = null,
CancellationToken token = default)
{
await SendHttpRequest($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectId.Type}/{objectId.Id}/links", request, HttpMethod.Post, token);
await SendHttpRequest($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/objects/{objectId.Type}/{objectId.Id}/links", request, HttpMethod.Post, token);
}
public virtual async Task RemoveOnChainWalletLinks(string storeId, string cryptoCode,
@ -57,6 +57,6 @@ public partial class BTCPayServerClient
OnChainWalletObjectId link,
CancellationToken token = default)
{
await SendHttpRequest($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectId.Type}/{objectId.Id}/links/{link.Type}/{link.Id}", null, HttpMethod.Delete, token);
await SendHttpRequest($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/objects/{objectId.Type}/{objectId.Id}/links/{link.Type}/{link.Id}", null, HttpMethod.Delete, token);
}
}

@ -14,7 +14,7 @@ public partial class BTCPayServerClient
public virtual async Task<OnChainWalletOverviewData> ShowOnChainWalletOverview(string storeId, string cryptoCode,
CancellationToken token = default)
{
return await SendHttpRequest<OnChainWalletOverviewData>($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet", null, HttpMethod.Get, token);
return await SendHttpRequest<OnChainWalletOverviewData>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet", null, HttpMethod.Get, token);
}
public virtual async Task<OnChainWalletFeeRateData> GetOnChainFeeRate(string storeId, string cryptoCode, int? blockTarget = null,
CancellationToken token = default)
@ -24,13 +24,13 @@ public partial class BTCPayServerClient
{
queryParams.Add("blockTarget", blockTarget);
}
return await SendHttpRequest<OnChainWalletFeeRateData>($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/feeRate", queryParams, HttpMethod.Get, token);
return await SendHttpRequest<OnChainWalletFeeRateData>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/feerate", queryParams, HttpMethod.Get, token);
}
public virtual async Task<OnChainWalletAddressData> GetOnChainWalletReceiveAddress(string storeId, string cryptoCode, bool forceGenerate = false,
CancellationToken token = default)
{
return await SendHttpRequest<OnChainWalletAddressData>($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/address", new Dictionary<string, object>
return await SendHttpRequest<OnChainWalletAddressData>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/address", new Dictionary<string, object>
{
{"forceGenerate", forceGenerate}
}, HttpMethod.Get, token);
@ -39,7 +39,7 @@ public partial class BTCPayServerClient
public virtual async Task UnReserveOnChainWalletReceiveAddress(string storeId, string cryptoCode,
CancellationToken token = default)
{
await SendHttpRequest($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/address", null, HttpMethod.Delete, token);
await SendHttpRequest($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/address", null, HttpMethod.Delete, token);
}
public virtual async Task<IEnumerable<OnChainWalletTransactionData>> ShowOnChainWalletTransactions(
@ -59,14 +59,14 @@ public partial class BTCPayServerClient
{
query.Add(nameof(skip), skip);
}
return await SendHttpRequest<IEnumerable<OnChainWalletTransactionData>>($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions", query, HttpMethod.Get, token);
return await SendHttpRequest<IEnumerable<OnChainWalletTransactionData>>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/transactions", query, HttpMethod.Get, token);
}
public virtual async Task<OnChainWalletTransactionData> GetOnChainWalletTransaction(
string storeId, string cryptoCode, string transactionId,
CancellationToken token = default)
{
return await SendHttpRequest<OnChainWalletTransactionData>($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions/{transactionId}", null, HttpMethod.Get, token);
return await SendHttpRequest<OnChainWalletTransactionData>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/transactions/{transactionId}", null, HttpMethod.Get, token);
}
public virtual async Task<OnChainWalletTransactionData> PatchOnChainWalletTransaction(
@ -74,7 +74,7 @@ public partial class BTCPayServerClient
PatchOnChainTransactionRequest request,
bool force = false, CancellationToken token = default)
{
return await SendHttpRequest<PatchOnChainTransactionRequest, OnChainWalletTransactionData>($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions/{transactionId}",
return await SendHttpRequest<PatchOnChainTransactionRequest, OnChainWalletTransactionData>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/transactions/{transactionId}",
new Dictionary<string, object> { {"force", force} }, request, HttpMethod.Patch, token);
}
@ -82,7 +82,7 @@ public partial class BTCPayServerClient
string cryptoCode,
CancellationToken token = default)
{
return await SendHttpRequest<IEnumerable<OnChainWalletUTXOData>>($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/utxos", null, HttpMethod.Get, token);
return await SendHttpRequest<IEnumerable<OnChainWalletUTXOData>>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/utxos", null, HttpMethod.Get, token);
}
public virtual async Task<OnChainWalletTransactionData> CreateOnChainTransaction(string storeId,
@ -94,7 +94,7 @@ public partial class BTCPayServerClient
throw new ArgumentOutOfRangeException(nameof(request.ProceedWithBroadcast),
"Please use CreateOnChainTransactionButDoNotBroadcast when wanting to only create the transaction");
}
return await SendHttpRequest<OnChainWalletTransactionData>($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions", request, HttpMethod.Post, token);
return await SendHttpRequest<OnChainWalletTransactionData>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/transactions", request, HttpMethod.Post, token);
}
public virtual async Task<Transaction> CreateOnChainTransactionButDoNotBroadcast(string storeId,
@ -106,6 +106,6 @@ public partial class BTCPayServerClient
throw new ArgumentOutOfRangeException(nameof(request.ProceedWithBroadcast),
"Please use CreateOnChainTransaction when wanting to also broadcast the transaction");
}
return Transaction.Parse(await SendHttpRequest<string>($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions", request, HttpMethod.Post, token), network);
return Transaction.Parse(await SendHttpRequest<string>($"api/v1/stores/{storeId}/payment-methods/{cryptoCode}-CHAIN/wallet/transactions", request, HttpMethod.Post, token), network);
}
}

@ -0,0 +1,15 @@
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models;
public class AppItemStats
{
public string ItemCode { get; set; }
public string Title { get; set; }
public int SalesCount { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Total { get; set; }
public string TotalFormatted { get; set; }
}

@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models;
public class AppSalesStats
{
public int SalesCount { get; set; }
public IEnumerable<AppSalesStatsItem> Series { get; set; }
}
public class AppSalesStatsItem
{
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTime Date { get; set; }
public string Label { get; set; }
public int SalesCount { get; set; }
}

@ -19,7 +19,7 @@ namespace BTCPayServer.Client.Models
public DateTimeOffset? ExpiresAt { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? StartsAt { get; set; }
public string[] PaymentMethods { get; set; }
public string[] PayoutMethods { get; set; }
public bool AutoApproveClaims { get; set; }
}
}

@ -22,11 +22,12 @@ namespace BTCPayServer.Client.Models
public string PullPaymentId { get; set; }
public string Destination { get; set; }
public string PayoutMethodId { get; set; }
public string CryptoCode { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Amount { get; set; }
public decimal OriginalAmount { get; set; }
public string OriginalCurrency { get; set; }
public string PayoutCurrency { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal? PaymentMethodAmount { get; set; }
public decimal? PayoutAmount { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public PayoutState State { get; set; }
public int Revision { get; set; }

@ -4,6 +4,6 @@ namespace BTCPayServer.Client.Models
{
public string Name { get; set; }
public string FriendlyName { get; set; }
public string[] PaymentMethods { get; set; }
public string[] PayoutMethods { get; set; }
}
}

@ -17,6 +17,7 @@ namespace BTCPayServer.Client.Models
public string Website { get; set; }
public string BrandColor { get; set; }
public bool ApplyBrandColorToBackend { get; set; }
public string LogoUrl { get; set; }
public string CssUrl { get; set; }
public string PaymentSoundUrl { get; set; }

@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
namespace BTCPayServer.Client
{
@ -16,7 +18,7 @@ namespace BTCPayServer.Client
public const string CanUseLightningNodeInStore = "btcpay.store.canuselightningnode";
public const string CanModifyServerSettings = "btcpay.server.canmodifyserversettings";
public const string CanModifyStoreSettings = "btcpay.store.canmodifystoresettings";
public const string CanModifyStoreWebhooks = "btcpay.store.webhooks.canmodifywebhooks";
public const string CanModifyWebhooks = "btcpay.store.webhooks.canmodifywebhooks";
public const string CanModifyStoreSettingsUnscoped = "btcpay.store.canmodifystoresettings:";
public const string CanViewStoreSettings = "btcpay.store.canviewstoresettings";
public const string CanViewReports = "btcpay.store.canviewreports";
@ -48,7 +50,7 @@ namespace BTCPayServer.Client
yield return CanViewInvoices;
yield return CanCreateInvoice;
yield return CanModifyInvoices;
yield return CanModifyStoreWebhooks;
yield return CanModifyWebhooks;
yield return CanModifyServerSettings;
yield return CanModifyStoreSettings;
yield return CanViewStoreSettings;
@ -104,6 +106,16 @@ namespace BTCPayServer.Client
{
return policy.StartsWith("btcpay.user", StringComparison.OrdinalIgnoreCase);
}
private static readonly CultureInfo _culture = new (CultureInfo.InvariantCulture.Name);
public static string DisplayName(string policy)
{
var p = policy.Split(".");
if (p.Length < 3 || p[0] != "btcpay") return policy;
var constName = typeof(Policies).GetFields().Select(f => f.Name).FirstOrDefault(f => f.Equals(p[^1], StringComparison.OrdinalIgnoreCase));
var perm = string.IsNullOrEmpty(constName) ? string.Join(' ', p[2..]) : Regex.Replace(constName, "([A-Z])", " $1", RegexOptions.Compiled).Trim();
return $"{_culture.TextInfo.ToTitleCase(p[1])}: {_culture.TextInfo.ToTitleCase(perm)}";
}
}
public class PermissionSet
@ -247,7 +259,7 @@ namespace BTCPayServer.Client
Policies.CanManagePullPayments,
Policies.CanModifyInvoices,
Policies.CanViewStoreSettings,
Policies.CanModifyStoreWebhooks,
Policies.CanModifyWebhooks,
Policies.CanModifyPaymentRequests,
Policies.CanManagePayouts,
Policies.CanUseLightningNodeInStore);

@ -133,7 +133,7 @@ namespace BTCPayServer
public string GetTrackedDestination(Script scriptPubKey)
{
return scriptPubKey.Hash.ToString() + "#" + CryptoCode.ToUpperInvariant();
return scriptPubKey.Hash.ToString();
}
}

@ -39,7 +39,6 @@ namespace BTCPayServer.Data
public DbSet<PaymentRequestData> PaymentRequests { get; set; }
public DbSet<PaymentData> Payments { get; set; }
public DbSet<PayoutData> Payouts { get; set; }
public DbSet<PendingInvoiceData> PendingInvoices { get; set; }
public DbSet<PlannedTransaction> PlannedTransactions { get; set; }
public DbSet<PullPaymentData> PullPayments { get; set; }
public DbSet<RefundData> Refunds { get; set; }
@ -83,9 +82,8 @@ namespace BTCPayServer.Data
PairingCodeData.OnModelCreating(builder);
//PayjoinLock.OnModelCreating(builder);
PaymentRequestData.OnModelCreating(builder, Database);
PaymentData.OnModelCreating(builder, Database);
PaymentData.OnModelCreating(builder);
PayoutData.OnModelCreating(builder, Database);
PendingInvoiceData.OnModelCreating(builder);
//PlannedTransaction.OnModelCreating(builder);
PullPaymentData.OnModelCreating(builder, Database);
RefundData.OnModelCreating(builder);

@ -1,6 +1,4 @@
using System;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Models;
using Microsoft.EntityFrameworkCore;
@ -23,7 +21,7 @@ namespace BTCPayServer.Data
{
var builder = new DbContextOptionsBuilder<ApplicationDbContext>();
builder.UseLoggerFactory(LoggerFactory);
builder.AddInterceptors(Data.InvoiceData.MigrationInterceptor.Instance);
builder.AddInterceptors(MigrationInterceptor.Instance);
ConfigureBuilder(builder, npgsqlOptionsAction);
return new ApplicationDbContext(builder.Options);
}

@ -20,5 +20,7 @@
<ItemGroup>
<None Remove="DBScripts\001.InvoiceFunctions.sql" />
<None Remove="DBScripts\002.RefactorPayouts.sql" />
<None Remove="DBScripts\003.RefactorPendingInvoicesPayments.sql" />
<None Remove="DBScripts\004.MonitoredInvoices.sql" />
</ItemGroup>
</Project>

@ -0,0 +1,9 @@
CREATE OR REPLACE FUNCTION is_pending(status TEXT)
RETURNS BOOLEAN AS $$
SELECT status = 'Processing' OR status = 'New';
$$ LANGUAGE sql IMMUTABLE;
CREATE INDEX "IX_Invoices_Pending" ON "Invoices"((1)) WHERE is_pending("Status");
CREATE INDEX "IX_Payments_Pending" ON "Payments"((1)) WHERE is_pending("Status");
DROP TABLE "PendingInvoices";
ANALYZE "Invoices";

@ -0,0 +1,23 @@
CREATE OR REPLACE FUNCTION get_prompt(invoice_blob JSONB, payment_method_id TEXT)
RETURNS JSONB AS $$
SELECT invoice_blob->'prompts'->payment_method_id
$$ LANGUAGE sql IMMUTABLE;
CREATE OR REPLACE FUNCTION get_monitored_invoices(arg_payment_method_id TEXT, include_non_activated BOOLEAN)
RETURNS TABLE (invoice_id TEXT, payment_id TEXT, payment_method_id TEXT) AS $$
WITH cte AS (
-- Get all the invoices which are pending. Even if no payments.
SELECT i."Id" invoice_id, p."Id" payment_id, p."PaymentMethodId" payment_method_id FROM "Invoices" i LEFT JOIN "Payments" p ON i."Id" = p."InvoiceDataId"
WHERE is_pending(i."Status")
UNION ALL
-- For invoices not pending, take all of those which have pending payments
SELECT i."Id" invoice_id, p."Id" payment_id, p."PaymentMethodId" payment_method_id FROM "Invoices" i INNER JOIN "Payments" p ON i."Id" = p."InvoiceDataId"
WHERE is_pending(p."Status") AND NOT is_pending(i."Status"))
SELECT cte.* FROM cte
JOIN "Invoices" i ON cte.invoice_id=i."Id"
LEFT JOIN "Payments" p ON cte.payment_id=p."Id" AND cte.payment_method_id=p."PaymentMethodId"
WHERE (p."PaymentMethodId" IS NOT NULL AND p."PaymentMethodId" = arg_payment_method_id) OR
(p."PaymentMethodId" IS NULL AND get_prompt(i."Blob2", arg_payment_method_id) IS NOT NULL AND
(include_non_activated IS TRUE OR (get_prompt(i."Blob2", arg_payment_method_id)->'inactive')::BOOLEAN IS NOT TRUE));
$$ LANGUAGE SQL STABLE;

@ -9,6 +9,7 @@ namespace BTCPayServer.Data
public string Address { get; set; }
public InvoiceData InvoiceData { get; set; }
public string InvoiceDataId { get; set; }
public string PaymentMethodId { get; set; }
internal static void OnModelCreating(ModelBuilder builder)
@ -18,7 +19,7 @@ namespace BTCPayServer.Data
.WithMany(i => i.AddressInvoices).OnDelete(DeleteBehavior.Cascade);
builder.Entity<AddressInvoiceData>()
#pragma warning disable CS0618
.HasKey(o => o.Address);
.HasKey(o => new { o.Address, o.PaymentMethodId });
#pragma warning restore CS0618
}
}

@ -46,5 +46,6 @@ namespace BTCPayServer.Data
public bool ShowInvoiceStatusChangeHint { get; set; }
public string ImageUrl { get; set; }
public string Name { get; set; }
public string InvitationToken { get; set; }
}
}

@ -15,32 +15,13 @@ using Microsoft.EntityFrameworkCore.Diagnostics;
using BTCPayServer.Migrations;
using Newtonsoft.Json.Serialization;
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
using System.Threading.Tasks;
using System.Threading;
namespace BTCPayServer.Data
{
public partial class InvoiceData
public partial class InvoiceData : MigrationInterceptor.IHasMigration
{
/// <summary>
/// We have a migration running in the background that will migrate the data from the old blob to the new blob
/// Meanwhile, we need to make sure that invoices which haven't been migrated yet are migrated on the fly.
/// </summary>
public class MigrationInterceptor : IMaterializationInterceptor
{
public static readonly MigrationInterceptor Instance = new MigrationInterceptor();
public object InitializedInstance(MaterializationInterceptionData materializationData, object entity)
{
if (entity is InvoiceData invoiceData && invoiceData.Currency is null)
{
invoiceData.Migrate();
}
else if (entity is PaymentData paymentData && paymentData.Currency is null)
{
paymentData.Migrate();
}
return entity;
}
}
static HashSet<string> superflousProperties = new HashSet<string>()
{
"availableAddressHashes",
@ -74,13 +55,14 @@ namespace BTCPayServer.Data
};
#pragma warning disable CS0618 // Type or member is obsolete
public void Migrate()
public bool TryMigrate()
{
if (Currency is not null)
return;
return false;
if (Blob is not (null or { Length: 0 }))
{
Blob2 = MigrationExtensions.Unzip(Blob);
Blob2 = MigrationExtensions.SanitizeJSON(Blob2);
Blob = null;
}
var blob = JObject.Parse(Blob2);
@ -228,7 +210,7 @@ namespace BTCPayServer.Data
}
blob.ConvertNumberToString("price");
Currency = blob["currency"].Value<string>();
Currency = blob["currency"].Value<string>().ToUpperInvariant();
var isTopup = blob["type"]?.Value<string>() is "TopUp";
var amount = decimal.Parse(blob["price"].Value<string>(), CultureInfo.InvariantCulture);
Amount = isTopup && amount == 0 ? null : decimal.Parse(blob["price"].Value<string>(), CultureInfo.InvariantCulture);
@ -368,7 +350,11 @@ namespace BTCPayServer.Data
};
blob["version"] = 3;
Blob2 = blob.ToString(Formatting.None);
return true;
}
[NotMapped]
public bool Migrated { get; set; }
static string[] detailsRemoveDefault =
[
"paymentMethodFeeRate",

@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data
{
public partial class InvoiceData : IHasBlobUntyped
@ -25,14 +26,14 @@ namespace BTCPayServer.Data
public string ExceptionStatus { get; set; }
public List<AddressInvoiceData> AddressInvoices { get; set; }
public bool Archived { get; set; }
public List<PendingInvoiceData> PendingInvoices { get; set; }
public List<InvoiceSearchData> InvoiceSearchData { get; set; }
public List<RefundData> Refunds { get; set; }
public static string GetOrderId(string blob) => throw new NotSupportedException();
public static string GetItemCode(string blob) => throw new NotSupportedException();
public static bool IsPending(string status) => throw new NotSupportedException();
[Timestamp]
[Timestamp]
// With this, update of InvoiceData will fail if the row was modified by another process
public uint XMin { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
@ -50,7 +51,7 @@ namespace BTCPayServer.Data
.HasColumnType("NUMERIC");
builder.HasDbFunction(typeof(InvoiceData).GetMethod(nameof(GetOrderId), new[] { typeof(string) }), b => b.HasName("get_orderid"));
builder.HasDbFunction(typeof(InvoiceData).GetMethod(nameof(GetItemCode), new[] { typeof(string) }), b => b.HasName("get_itemcode"));
}
builder.HasDbFunction(typeof(InvoiceData).GetMethod(nameof(IsPending), new[] { typeof(string) }), b => b.HasName("is_pending"));
}
}
}

@ -147,5 +147,8 @@ namespace BTCPayServer.Data
return $"{splitted[0]}-CHAIN";
throw new NotSupportedException("Unknown payment id " + paymentMethodId);
}
// Make postgres happy
public static string SanitizeJSON(string json) => json.Replace("\\u0000", string.Empty, StringComparison.OrdinalIgnoreCase);
}
}

@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Data
{
/// <summary>
/// We have a migration running in the background that will migrate the data from the old blob to the new blob
/// Meanwhile, we need to make sure that invoices which haven't been migrated yet are migrated on the fly.
/// </summary>
public class MigrationInterceptor : IMaterializationInterceptor, ISaveChangesInterceptor
{
public interface IHasMigration
{
bool TryMigrate();
bool Migrated { get; set; }
}
public static readonly MigrationInterceptor Instance = new MigrationInterceptor();
public object InitializedInstance(MaterializationInterceptionData materializationData, object entity)
{
if (entity is IHasMigration hasMigration && hasMigration.TryMigrate())
{
hasMigration.Migrated = true;
}
return entity;
}
public ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default(CancellationToken))
{
foreach (var entry in eventData.Context.ChangeTracker.Entries())
{
if (entry is { Entity: IHasMigration { Migrated: true }, State: EntityState.Modified })
// It seems doing nothing, but this actually set all properties as modified
entry.State = EntityState.Modified;
}
return new ValueTask<InterceptionResult<int>>(result);
}
}
}

@ -1,10 +1,13 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Reflection.Metadata;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Migrations;
using Microsoft.EntityFrameworkCore.ChangeTracking.Internal;
using NBitcoin;
using NBitcoin.Altcoins;
using NBitcoin.DataEncoders;
@ -13,16 +16,17 @@ using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data
{
public partial class PaymentData
public partial class PaymentData : MigrationInterceptor.IHasMigration
{
public void Migrate()
public bool TryMigrate()
{
#pragma warning disable CS0618 // Type or member is obsolete
if (Currency is not null)
return;
return false;
if (Blob is not (null or { Length: 0 }))
{
Blob2 = MigrationExtensions.Unzip(Blob);
Blob2 = MigrationExtensions.SanitizeJSON(Blob2);
Blob = null;
}
var blob = JObject.Parse(Blob2);
@ -42,9 +46,10 @@ namespace BTCPayServer.Data
}
var cryptoCode = blob["cryptoCode"].Value<string>();
Type = cryptoCode + "_" + blob["cryptoPaymentDataType"].Value<string>();
Type = MigrationExtensions.MigratePaymentMethodId(Type);
var divisibility = MigrationExtensions.GetDivisibility(Type);
MigratedPaymentMethodId = PaymentMethodId;
PaymentMethodId = cryptoCode + "_" + blob["cryptoPaymentDataType"].Value<string>();
PaymentMethodId = MigrationExtensions.MigratePaymentMethodId(PaymentMethodId);
var divisibility = MigrationExtensions.GetDivisibility(PaymentMethodId);
Currency = blob["cryptoCode"].Value<string>();
blob.Remove("cryptoCode");
blob.Remove("cryptoPaymentDataType");
@ -157,7 +162,13 @@ namespace BTCPayServer.Data
Blob2 = blob.ToString(Formatting.None);
Accounted = null;
#pragma warning restore CS0618 // Type or member is obsolete
return true;
}
[NotMapped]
public bool Migrated { get; set; }
[NotMapped]
[EditorBrowsable(EditorBrowsableState.Never)]
public string MigratedPaymentMethodId { get; set; }
static readonly DateTimeOffset unixRef = new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero);
public static long DateTimeToMilliUnixTime(in DateTime time)

@ -1,4 +1,5 @@
using System;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@ -26,13 +27,15 @@ namespace BTCPayServer.Data
[Obsolete("Use Blob2 instead")]
public byte[] Blob { get; set; }
public string Blob2 { get; set; }
public string Type { get; set; }
public string PaymentMethodId { get; set; }
[Obsolete("Use Status instead")]
public bool? Accounted { get; set; }
public PaymentStatus? Status { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
public static bool IsPending(PaymentStatus? status) => throw new NotSupportedException();
internal static void OnModelCreating(ModelBuilder builder)
{
builder.Entity<PaymentData>()
.HasKey(o => new { o.Id, o.PaymentMethodId });
builder.Entity<PaymentData>()
.HasOne(o => o.InvoiceData)
.WithMany(i => i.Payments).OnDelete(DeleteBehavior.Cascade);
@ -47,6 +50,7 @@ namespace BTCPayServer.Data
builder.Entity<PaymentData>()
.Property(o => o.Amount)
.HasColumnType("NUMERIC");
builder.HasDbFunction(typeof(PaymentData).GetMethod(nameof(IsPending), new[] { typeof(PaymentStatus?) }), b => b.HasName("is_pending"));
}
}
}

@ -1,18 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Data
{
public class PendingInvoiceData
{
public string Id { get; set; }
public InvoiceData InvoiceData { get; set; }
internal static void OnModelCreating(ModelBuilder builder)
{
builder.Entity<PendingInvoiceData>()
.HasOne(o => o.InvoiceData)
.WithMany(o => o.PendingInvoices)
.HasForeignKey(o => o.Id).OnDelete(DeleteBehavior.Cascade);
}
}
}

@ -1,30 +0,0 @@
using System;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240405052858_cleanup_address_invoices")]
public partial class cleanup_address_invoices : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
DELETE FROM ""AddressInvoices"" WHERE ""Address"" LIKE '%_LightningLike';
ALTER TABLE ""AddressInvoices"" DROP COLUMN IF EXISTS ""CreatedTime"";
");
migrationBuilder.Sql(@"VACUUM (FULL, ANALYZE) ""AddressInvoices"";", true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

@ -11,5 +11,30 @@ namespace BTCPayServer.Migrations
[DBScript("002.RefactorPayouts.sql")]
public partial class migratepayouts : DBScriptsMigration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
base.Up(migrationBuilder);
migrationBuilder.RenameColumn(
name: "Destination",
table: "Payouts",
newName: "DedupId");
migrationBuilder.RenameIndex(
name: "IX_Payouts_Destination_State",
table: "Payouts",
newName: "IX_Payouts_DedupId_State");
migrationBuilder.RenameColumn(
name: "PaymentMethod",
table: "PayoutProcessors",
newName: "PayoutMethodId");
migrationBuilder.Sql("""
UPDATE "PayoutProcessors"
SET
"PayoutMethodId" = CASE WHEN STRPOS("PayoutMethodId", '_') = 0 THEN "PayoutMethodId" || '-CHAIN'
WHEN STRPOS("PayoutMethodId", '_LightningLike') > 0 THEN split_part("PayoutMethodId", '_LightningLike', 1) || '-LN'
WHEN STRPOS("PayoutMethodId", '_LNURLPAY') > 0 THEN split_part("PayoutMethodId",'_LNURLPAY', 1) || '-LN'
ELSE "PayoutMethodId" END
""");
}
}
}

@ -0,0 +1,54 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Newtonsoft.Json;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240904092905_UpdateStoreOwnerRole")]
public partial class UpdateStoreOwnerRole : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
"StoreRoles",
keyColumns: new[] { "Id" },
keyColumnTypes: new[] { "TEXT" },
keyValues: new[] { "Owner" },
columns: new[] { "Permissions" },
columnTypes: new[] { "TEXT[]" },
values: new object[]
{
new[]
{
"btcpay.store.canmodifystoresettings"
}
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
"StoreRoles",
keyColumns: new[] { "Id" },
keyColumnTypes: new[] { "TEXT" },
keyValues: new[] { "Owner" },
columns: new[] { "Permissions" },
columnTypes: new[] { "TEXT[]" },
values: new object[]
{
new[]
{
"btcpay.store.canmodifystoresettings",
"btcpay.store.cantradecustodianaccount",
"btcpay.store.canwithdrawfromcustodianaccount",
"btcpay.store.candeposittocustodianaccount"
}
});
}
}
}

@ -1,36 +0,0 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240906010127_renamecol")]
public partial class renamecol : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Destination",
table: "Payouts",
newName: "DedupId");
migrationBuilder.RenameIndex(
name: "IX_Payouts_Destination_State",
table: "Payouts",
newName: "IX_Payouts_DedupId_State");
migrationBuilder.RenameColumn(
name: "PaymentMethod",
table: "PayoutProcessors",
newName: "PayoutMethodId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

@ -0,0 +1,15 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240913034505_refactorpendinginvoicespayments")]
[DBScript("003.RefactorPendingInvoicesPayments.sql")]
public partial class refactorpendinginvoicespayments : DBScriptsMigration
{
}
}

@ -0,0 +1,80 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240919085726_refactorinvoiceaddress")]
public partial class refactorinvoiceaddress : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_AddressInvoices",
table: "AddressInvoices");
migrationBuilder.AddColumn<string>(
name: "PaymentMethodId",
table: "AddressInvoices",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.Sql("""
UPDATE "AddressInvoices"
SET
"Address" = (string_to_array("Address", '#'))[1],
"PaymentMethodId" = CASE WHEN (string_to_array("Address", '#'))[2] IS NULL THEN 'BTC-CHAIN'
WHEN STRPOS((string_to_array("Address", '#'))[2], '_') = 0 THEN (string_to_array("Address", '#'))[2] || '-CHAIN'
WHEN STRPOS((string_to_array("Address", '#'))[2], '_MoneroLike') > 0 THEN replace((string_to_array("Address", '#'))[2],'_MoneroLike','-CHAIN')
WHEN STRPOS((string_to_array("Address", '#'))[2], '_ZcashLike') > 0 THEN replace((string_to_array("Address", '#'))[2],'_ZcashLike','-CHAIN')
ELSE '' END;
ALTER TABLE "AddressInvoices" DROP COLUMN IF EXISTS "CreatedTime";
DELETE FROM "AddressInvoices" WHERE "PaymentMethodId" = '';
""");
migrationBuilder.AddPrimaryKey(
name: "PK_AddressInvoices",
table: "AddressInvoices",
columns: new[] { "Address", "PaymentMethodId" });
migrationBuilder.Sql("VACUUM (ANALYZE) \"AddressInvoices\";", true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_AddressInvoices",
table: "AddressInvoices");
migrationBuilder.DropColumn(
name: "PaymentMethodId",
table: "AddressInvoices");
migrationBuilder.AddPrimaryKey(
name: "PK_AddressInvoices",
table: "AddressInvoices",
column: "Address");
migrationBuilder.CreateTable(
name: "PendingInvoices",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PendingInvoices", x => x.Id);
table.ForeignKey(
name: "FK_PendingInvoices_Invoices_Id",
column: x => x.Id,
principalTable: "Invoices",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
}
}

@ -0,0 +1,38 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240923065254_refactorpayments")]
public partial class refactorpayments : DBScriptsMigration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_Payments",
table: "Payments");
migrationBuilder.RenameColumn(
name: "Type",
table: "Payments",
newName: "PaymentMethodId");
migrationBuilder.Sql("UPDATE \"Payments\" SET \"PaymentMethodId\"='' WHERE \"PaymentMethodId\" IS NULL;");
migrationBuilder.AddPrimaryKey(
name: "PK_Payments",
table: "Payments",
columns: new[] { "Id", "PaymentMethodId" });
base.Up(migrationBuilder);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

@ -0,0 +1,15 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240924065254_monitoredinvoices")]
[DBScript("004.MonitoredInvoices.sql")]
public partial class monitoredinvoices : DBScriptsMigration
{
}
}

@ -63,10 +63,13 @@ namespace BTCPayServer.Migrations
b.Property<string>("Address")
.HasColumnType("text");
b.Property<string>("PaymentMethodId")
.HasColumnType("text");
b.Property<string>("InvoiceDataId")
.HasColumnType("text");
b.HasKey("Address");
b.HasKey("Address", "PaymentMethodId");
b.HasIndex("InvoiceDataId");
@ -479,6 +482,9 @@ namespace BTCPayServer.Migrations
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("PaymentMethodId")
.HasColumnType("text");
b.Property<bool?>("Accounted")
.HasColumnType("boolean");
@ -503,10 +509,7 @@ namespace BTCPayServer.Migrations
b.Property<string>("Status")
.HasColumnType("text");
b.Property<string>("Type")
.HasColumnType("text");
b.HasKey("Id");
b.HasKey("Id", "PaymentMethodId");
b.HasIndex("InvoiceDataId");
@ -634,16 +637,6 @@ namespace BTCPayServer.Migrations
b.ToTable("PayoutProcessors");
});
modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("PendingInvoices");
});
modelBuilder.Entity("BTCPayServer.Data.PlannedTransaction", b =>
{
b.Property<string>("Id")
@ -1331,17 +1324,6 @@ namespace BTCPayServer.Migrations
b.Navigation("Store");
});
modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b =>
{
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")
.WithMany("PendingInvoices")
.HasForeignKey("Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("InvoiceData");
});
modelBuilder.Entity("BTCPayServer.Data.PullPaymentData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
@ -1572,8 +1554,6 @@ namespace BTCPayServer.Migrations
b.Navigation("Payments");
b.Navigation("PendingInvoices");
b.Navigation("Refunds");
});

@ -34,7 +34,7 @@
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.15" />
<PackageReference Include="Selenium.Support" Version="4.1.1" />
<PackageReference Include="Selenium.WebDriver" Version="4.22.0" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="125.0.6422.14100" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="128.0.6613.11900" />
<PackageReference Include="xunit" Version="2.6.6" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
<PrivateAssets>all</PrivateAssets>

@ -177,7 +177,7 @@ namespace BTCPayServer.Tests
l.AddFilter("System.Net.Http.HttpClient", LogLevel.Critical);
l.SetMinimumLevel(LogLevel.Information)
.AddFilter("Microsoft", LogLevel.Error)
.AddFilter("Hangfire", LogLevel.Error)
.AddFilter("Microsoft.EntityFrameworkCore.Migrations", LogLevel.Information)
.AddFilter("Fido2NetLib.DistributedCacheMetadataService", LogLevel.Error)
.AddProvider(LoggerProvider);
});

@ -58,8 +58,8 @@ namespace BTCPayServer.Tests
var qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value");
var clipboard = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
var payUrl = s.Driver.FindElement(By.Id("PayInWallet")).GetAttribute("href");
var address = s.Driver.FindElement(By.CssSelector("#Address_BTC-CHAIN .truncate-center-start")).Text;
Assert.StartsWith("bcrt", s.Driver.FindElement(By.CssSelector("#Address_BTC-CHAIN .truncate-center-start")).Text);
var address = s.Driver.FindElement(By.CssSelector("#Address_BTC-CHAIN .truncate-center")).GetAttribute("data-text");
Assert.StartsWith("bcrt", address);
Assert.DoesNotContain("lightning=", payUrl);
Assert.Equal($"bitcoin:{address}", payUrl);
Assert.Equal($"bitcoin:{address}", clipboard);
@ -80,7 +80,7 @@ namespace BTCPayServer.Tests
{
payUrl = s.Driver.FindElement(By.Id("PayInWallet")).GetAttribute("href");
Assert.StartsWith("lightning:lnurl", payUrl);
Assert.StartsWith("lnurl", s.Driver.WaitForElement(By.CssSelector("#Lightning_BTC-CHAIN .truncate-center-start")).Text);
Assert.StartsWith("lnurl", s.Driver.WaitForElement(By.CssSelector("#Lightning_BTC-CHAIN .truncate-center")).GetAttribute("data-text"));
s.Driver.ElementDoesNotExist(By.Id("Address_BTC-CHAIN"));
});
@ -94,7 +94,7 @@ namespace BTCPayServer.Tests
qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value");
clipboard = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
payUrl = s.Driver.FindElement(By.Id("PayInWallet")).GetAttribute("href");
address = s.Driver.FindElement(By.CssSelector("#Lightning_BTC-LN .truncate-center-start")).Text;
address = s.Driver.FindElement(By.CssSelector("#Lightning_BTC-LN .truncate-center")).GetAttribute("data-text");
Assert.Equal($"lightning:{address}", payUrl);
Assert.Equal($"lightning:{address}", clipboard);
Assert.Equal($"lightning:{address.ToUpperInvariant()}", qrValue);
@ -147,7 +147,7 @@ namespace BTCPayServer.Tests
invoiceId = s.CreateInvoice(2100, "EUR");
s.GoToInvoiceCheckout(invoiceId);
await Task.Delay(200);
address = s.Driver.FindElement(By.CssSelector("#Address_BTC-CHAIN .truncate-center-start")).Text;
address = s.Driver.FindElement(By.CssSelector("#Address_BTC-CHAIN .truncate-center")).GetAttribute("data-text");
var amountFraction = "0.00001";
await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(address, Network.RegTest),
Money.Parse(amountFraction));
@ -261,8 +261,8 @@ namespace BTCPayServer.Tests
qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value");
clipboard = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
payUrl = s.Driver.FindElement(By.Id("PayInWallet")).GetAttribute("href");
var copyAddressOnchain = s.Driver.FindElement(By.CssSelector("#Address_BTC-CHAIN .truncate-center-start")).Text;
var copyAddressLightning = s.Driver.FindElement(By.CssSelector("#Lightning_BTC-CHAIN .truncate-center-start")).Text;
var copyAddressOnchain = s.Driver.FindElement(By.CssSelector("#Address_BTC-CHAIN .truncate-center")).GetAttribute("data-text");
var copyAddressLightning = s.Driver.FindElement(By.CssSelector("#Lightning_BTC-CHAIN .truncate-center")).GetAttribute("data-text");
Assert.StartsWith($"bitcoin:{copyAddressOnchain}?amount=", payUrl);
Assert.Contains("?amount=", payUrl);
Assert.Contains("&lightning=", payUrl);
@ -327,8 +327,8 @@ namespace BTCPayServer.Tests
qrValue = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-qr-value");
clipboard = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-clipboard");
payUrl = s.Driver.FindElement(By.Id("PayInWallet")).GetAttribute("href");
copyAddressOnchain = s.Driver.FindElement(By.CssSelector("#Address_BTC-CHAIN .truncate-center-start")).Text;
copyAddressLightning = s.Driver.FindElement(By.CssSelector("#Lightning_BTC-CHAIN .truncate-center-start")).Text;
copyAddressOnchain = s.Driver.FindElement(By.CssSelector("#Address_BTC-CHAIN .truncate-center")).GetAttribute("data-text");
copyAddressLightning = s.Driver.FindElement(By.CssSelector("#Lightning_BTC-CHAIN .truncate-center")).GetAttribute("data-text");
Assert.StartsWith($"bitcoin:{copyAddressOnchain}", payUrl);
Assert.Contains("?lightning=lnurl", payUrl);
Assert.DoesNotContain("amount=", payUrl);

@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Data;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Tests.Logging;
using Dapper;
using Microsoft.EntityFrameworkCore;
@ -42,6 +43,13 @@ namespace BTCPayServer.Tests
}), _loggerFactory);
}
public InvoiceRepository GetInvoiceRepository()
{
var logs = new BTCPayServer.Logging.Logs();
logs.Configure(_loggerFactory);
return new InvoiceRepository(CreateContextFactory(), new EventAggregator(logs));
}
public ApplicationDbContext CreateContext() => CreateContextFactory().CreateContext();
public async Task MigrateAsync()
@ -59,18 +67,21 @@ namespace BTCPayServer.Tests
await conn.ExecuteAsync($"CREATE DATABASE \"{dbname}\";");
}
public async Task MigrateUntil(string migration)
public async Task MigrateUntil(string migration = null)
{
using var ctx = CreateContext();
var db = ctx.Database.GetDbConnection();
await EnsureCreatedAsync();
var migrations = ctx.Database.GetMigrations().ToArray();
var untilMigrationIdx = Array.IndexOf(migrations, migration);
if (untilMigrationIdx == -1)
throw new InvalidOperationException($"Migration {migration} not found");
notAppliedMigrations = migrations[untilMigrationIdx..];
await db.ExecuteAsync("CREATE TABLE IF NOT EXISTS \"__EFMigrationsHistory\" (\"MigrationId\" TEXT, \"ProductVersion\" TEXT)");
await db.ExecuteAsync("INSERT INTO \"__EFMigrationsHistory\" VALUES (@migration, '8.0.0')", notAppliedMigrations.Select(m => new { migration = m }).ToArray());
if (migration is not null)
{
var untilMigrationIdx = Array.IndexOf(migrations, migration);
if (untilMigrationIdx == -1)
throw new InvalidOperationException($"Migration {migration} not found");
notAppliedMigrations = migrations[untilMigrationIdx..];
await db.ExecuteAsync("CREATE TABLE IF NOT EXISTS \"__EFMigrationsHistory\" (\"MigrationId\" TEXT, \"ProductVersion\" TEXT)");
await db.ExecuteAsync("INSERT INTO \"__EFMigrationsHistory\" VALUES (@migration, '8.0.0')", notAppliedMigrations.Select(m => new { migration = m }).ToArray());
}
await ctx.Database.MigrateAsync();
}

@ -3,7 +3,9 @@ using System.Threading.Tasks;
using BTCPayServer.Payments;
using Dapper;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.Altcoins;
using NBitpayClient;
using Newtonsoft.Json.Linq;
using Xunit;
using Xunit.Abstractions;
@ -18,6 +20,130 @@ namespace BTCPayServer.Tests
{
}
[Fact]
public async Task CanQueryMonitoredInvoices()
{
var tester = CreateDBTester();
await tester.MigrateUntil();
var invoiceRepository = tester.GetInvoiceRepository();
using var ctx = tester.CreateContext();
var conn = ctx.Database.GetDbConnection();
async Task AddPrompt(string invoiceId, string paymentMethodId, bool activated = true)
{
JObject prompt = new JObject();
if (!activated)
prompt["inactive"] = true;
prompt["currency"] = "USD";
var query = """
UPDATE "Invoices" SET "Blob2" = jsonb_set('{"prompts": {}}'::JSONB || COALESCE("Blob2",'{}'), ARRAY['prompts','@paymentMethodId'], '@prompt'::JSONB)
WHERE "Id" = '@invoiceId'
""";
query = query.Replace("@paymentMethodId", paymentMethodId);
query = query.Replace("@prompt", prompt.ToString());
query = query.Replace("@invoiceId", invoiceId);
Assert.Equal(1, await conn.ExecuteAsync(query));
}
await conn.ExecuteAsync("""
INSERT INTO "Invoices" ("Id", "Created", "Status","Currency") VALUES
('BTCOnly', NOW(), 'New', 'USD'),
('LTCOnly', NOW(), 'New', 'USD'),
('LTCAndBTC', NOW(), 'New', 'USD'),
('LTCAndBTCLazy', NOW(), 'New', 'USD')
""");
foreach (var invoiceId in new string[] { "LTCOnly", "LTCAndBTCLazy", "LTCAndBTC" })
{
await AddPrompt(invoiceId, "LTC-CHAIN", true);
}
foreach (var invoiceId in new string[] { "BTCOnly", "LTCAndBTC" })
{
await AddPrompt(invoiceId, "BTC-CHAIN", true);
}
await AddPrompt("LTCAndBTCLazy", "BTC-CHAIN", false);
var btc = PaymentMethodId.Parse("BTC-CHAIN");
var ltc = PaymentMethodId.Parse("LTC-CHAIN");
var invoices = await invoiceRepository.GetMonitoredInvoices(btc);
Assert.Equal(2, invoices.Length);
foreach (var invoiceId in new[] { "BTCOnly", "LTCAndBTC" })
{
Assert.Contains(invoices, i => i.Id == invoiceId);
}
invoices = await invoiceRepository.GetMonitoredInvoices(btc, true);
Assert.Equal(3, invoices.Length);
foreach (var invoiceId in new[] { "BTCOnly", "LTCAndBTC", "LTCAndBTCLazy" })
{
Assert.Contains(invoices, i => i.Id == invoiceId);
}
invoices = await invoiceRepository.GetMonitoredInvoices(ltc);
Assert.Equal(3, invoices.Length);
foreach (var invoiceId in new[] { "LTCAndBTC", "LTCAndBTC", "LTCAndBTCLazy" })
{
Assert.Contains(invoices, i => i.Id == invoiceId);
}
await conn.ExecuteAsync("""
INSERT INTO "Payments" ("Id", "InvoiceDataId", "PaymentMethodId", "Status", "Blob2", "Created", "Amount", "Currency") VALUES
('1','LTCAndBTC', 'LTC-CHAIN', 'Processing', '{}'::JSONB, NOW(), 123, 'USD'),
('2','LTCAndBTC', 'BTC-CHAIN', 'Processing', '{}'::JSONB, NOW(), 123, 'USD'),
('3','LTCAndBTC', 'BTC-CHAIN', 'Processing', '{}'::JSONB, NOW(), 123, 'USD'),
('4','LTCAndBTC', 'BTC-CHAIN', 'Settled', '{}'::JSONB, NOW(), 123, 'USD');
INSERT INTO "AddressInvoices" ("InvoiceDataId", "Address", "PaymentMethodId") VALUES
('LTCAndBTC', 'BTC1', 'BTC-CHAIN'),
('LTCAndBTC', 'BTC2', 'BTC-CHAIN'),
('LTCAndBTC', 'LTC1', 'LTC-CHAIN');
""");
var invoice = Assert.Single(await invoiceRepository.GetMonitoredInvoices(ltc), i => i.Id == "LTCAndBTC");
var payment = Assert.Single(invoice.GetPayments(false));
Assert.Equal("1", payment.Id);
foreach (var includeNonActivated in new[] { true, false })
{
invoices = await invoiceRepository.GetMonitoredInvoices(btc, includeNonActivated);
invoice = Assert.Single(invoices, i => i.Id == "LTCAndBTC");
var payments = invoice.GetPayments(false);
Assert.Equal(3, payments.Count);
foreach (var paymentId in new[] { "2", "3", "4" })
{
Assert.Contains(payments, p => p.Id == paymentId);
}
Assert.Equal(2, invoice.Addresses.Count);
foreach (var addr in new[] { "BTC1", "BTC2" })
{
Assert.Contains(invoice.Addresses, p => p.Address == addr);
}
if (!includeNonActivated)
Assert.DoesNotContain(invoices, i => i.Id == "LTCAndBTCLazy");
else
Assert.Contains(invoices, i => i.Id == "LTCAndBTCLazy");
}
}
[Fact]
public async Task CanMigrateInvoiceAddresses()
{
var tester = CreateDBTester();
await tester.MigrateUntil("20240919085726_refactorinvoiceaddress");
using var ctx = tester.CreateContext();
var conn = ctx.Database.GetDbConnection();
await conn.ExecuteAsync("INSERT INTO \"Invoices\" (\"Id\", \"Created\") VALUES ('i', NOW())");
await conn.ExecuteAsync(
"INSERT INTO \"AddressInvoices\" VALUES ('aaa#BTC', 'i'),('bbb','i'),('ccc#BTC_LNU', 'i'),('ddd#XMR_MoneroLike', 'i'),('eee#ZEC_ZcashLike', 'i')");
await tester.ContinueMigration();
foreach (var v in new[] { ("aaa", "BTC-CHAIN"), ("bbb", "BTC-CHAIN"), ("ddd", "XMR-CHAIN") , ("eee", "ZEC-CHAIN") })
{
var ok = await conn.ExecuteScalarAsync<bool>("SELECT 't'::BOOLEAN FROM \"AddressInvoices\" WHERE \"Address\"=@a AND \"PaymentMethodId\"=@b", new { a = v.Item1, b = v.Item2 });
Assert.True(ok);
}
var notok = await conn.ExecuteScalarAsync<bool>("SELECT 't'::BOOLEAN FROM \"AddressInvoices\" WHERE \"Address\"='ccc'");
Assert.False(notok);
}
[Fact]
public async Task CanMigratePayoutsAndPullPayments()
{

@ -20,6 +20,7 @@ using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Hosting;
using BTCPayServer.JsonConverters;
using BTCPayServer.Lightning;
using BTCPayServer.Logging;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
@ -505,7 +506,6 @@ namespace BTCPayServer.Tests
var paymentMethod = entity.GetPaymentPrompts().TryGet(PaymentTypes.CHAIN.GetPaymentMethodId("BTC"));
var accounting = paymentMethod.Calculate();
Assert.Equal(1.0m, accounting.ToSmallestUnit(Money.Satoshis(1.0m).ToDecimal(MoneyUnit.BTC)));
Assert.Equal(1.1m, accounting.Due);
Assert.Equal(1.1m, accounting.TotalDue);
@ -2246,7 +2246,7 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
Data.InvoiceData data = new Data.InvoiceData();
obj = data;
data.Blob2 = v.Input.ToString();
data.Migrate();
data.TryMigrate();
var actual = JObject.Parse(data.Blob2);
AssertSameJson(v.Expected, actual);
if (!v.SkipRountripTest)
@ -2266,7 +2266,7 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
//data.
obj = data;
data.Blob2 = v.Input.ToString();
data.Migrate();
data.TryMigrate();
var actual = JObject.Parse(data.Blob2);
AssertSameJson(v.Expected, actual);
if (!v.SkipRountripTest)
@ -2381,7 +2381,7 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
});
var data = new Data.InvoiceData();
data.Blob2 = o.ToString();
data.Migrate();
data.TryMigrate();
var migrated = JObject.Parse(data.Blob2);
return migrated["prompts"]["BTC-CHAIN"]["details"]["accountDerivation"].Value<string>();
})

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
@ -13,9 +14,14 @@ using BTCPayServer.Events;
using BTCPayServer.Lightning;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.NTag424;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Stores;
@ -30,6 +36,7 @@ using Xunit;
using Xunit.Abstractions;
using Xunit.Sdk;
using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest;
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
namespace BTCPayServer.Tests
{
@ -678,6 +685,93 @@ namespace BTCPayServer.Tests
Assert.False(apps[2].Archived);
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanGetAppStats()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
await user.GrantAccessAsync(true);
await user.MakeAdmin();
await user.RegisterDerivationSchemeAsync("BTC");
var client = await user.CreateClient();
var item1 = new ViewPointOfSaleViewModel.Item { Id = "item1", Title = "Item 1", Price = 1, PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed };
var item2 = new ViewPointOfSaleViewModel.Item { Id = "item2", Title = "Item 2", Price = 2, PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed };
var item3 = new ViewPointOfSaleViewModel.Item { Id = "item3", Title = "Item 3", Price = 3, PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed };
var posItems = AppService.SerializeTemplate([item1, item2, item3]);
var posApp = await client.CreatePointOfSaleApp(user.StoreId, new PointOfSaleAppRequest { AppName = "test pos", Template = posItems, });
var crowdfundApp = await client.CreateCrowdfundApp(user.StoreId, new CrowdfundAppRequest { AppName = "test crowdfund" });
// empty states
var posSales = await client.GetAppSales(posApp.Id);
Assert.NotNull(posSales);
Assert.Equal(0, posSales.SalesCount);
Assert.Equal(7, posSales.Series.Count());
var crowdfundSales = await client.GetAppSales(crowdfundApp.Id);
Assert.NotNull(crowdfundSales);
Assert.Equal(0, crowdfundSales.SalesCount);
Assert.Equal(7, crowdfundSales.Series.Count());
var posTopItems = await client.GetAppTopItems(posApp.Id);
Assert.Empty(posTopItems);
var crowdfundItems = await client.GetAppTopItems(crowdfundApp.Id);
Assert.Empty(crowdfundItems);
// with sales - fiddle invoices via the UI controller
var uiPosController = tester.PayTester.GetController<UIPointOfSaleController>();
var action = Assert.IsType<RedirectToActionResult>(uiPosController.ViewPointOfSale(posApp.Id, PosViewType.Static, 1, choiceKey: item1.Id).GetAwaiter().GetResult());
Assert.Equal(nameof(UIInvoiceController.Checkout), action.ActionName);
Assert.True(action.RouteValues!.TryGetValue("invoiceId", out var i1Id));
var cart = new JObject {
["cart"] = new JArray
{
new JObject { ["id"] = item2.Id, ["count"] = 4 },
new JObject { ["id"] = item3.Id, ["count"] = 2 }
},
["subTotal"] = 14,
["total"] = 14,
["amounts"] = new JArray()
}.ToString();
action = Assert.IsType<RedirectToActionResult>(uiPosController.ViewPointOfSale(posApp.Id, PosViewType.Cart, 7, posData: cart).GetAwaiter().GetResult());
Assert.Equal(nameof(UIInvoiceController.Checkout), action.ActionName);
Assert.True(action.RouteValues!.TryGetValue("invoiceId", out var i2Id));
await user.PayInvoice(i1Id!.ToString());
await user.PayInvoice(i2Id!.ToString());
posSales = await client.GetAppSales(posApp.Id);
Assert.Equal(7, posSales.SalesCount);
Assert.Equal(7, posSales.Series.Count());
Assert.Equal(0, posSales.Series.First().SalesCount);
Assert.Equal(7, posSales.Series.Last().SalesCount);
posTopItems = await client.GetAppTopItems(posApp.Id);
Assert.Equal(3, posTopItems.Count);
Assert.Equal(item2.Id, posTopItems[0].ItemCode);
Assert.Equal(4, posTopItems[0].SalesCount);
Assert.Equal(item3.Id, posTopItems[1].ItemCode);
Assert.Equal(2, posTopItems[1].SalesCount);
Assert.Equal(item1.Id, posTopItems[2].ItemCode);
Assert.Equal(1, posTopItems[2].SalesCount);
// with count and offset
posTopItems = await client.GetAppTopItems(posApp.Id,1, 5);
Assert.Equal(2, posTopItems.Count);
Assert.Equal(item3.Id, posTopItems[0].ItemCode);
Assert.Equal(2, posTopItems[0].SalesCount);
Assert.Equal(item1.Id, posTopItems[1].ItemCode);
Assert.Equal(1, posTopItems[1].SalesCount);
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanDeleteUsersViaApi()
@ -1010,7 +1104,7 @@ namespace BTCPayServer.Tests
Description = "Test description",
Amount = 12.3m,
Currency = "BTC",
PaymentMethods = new[] { "BTC" }
PayoutMethods = new[] { "BTC" }
});
void VerifyResult()
@ -1041,7 +1135,7 @@ namespace BTCPayServer.Tests
Name = "Test 2",
Amount = 12.3m,
Currency = "BTC",
PaymentMethods = new[] { "BTC" },
PayoutMethods = new[] { "BTC" },
BOLT11Expiration = TimeSpan.FromDays(31.0)
});
Assert.Equal(TimeSpan.FromDays(31.0), test2.BOLT11Expiration);
@ -1088,13 +1182,13 @@ namespace BTCPayServer.Tests
payouts = await unauthenticated.GetPayouts(pps[0].Id);
var payout2 = Assert.Single(payouts);
Assert.Equal(payout.Amount, payout2.Amount);
Assert.Equal(payout.OriginalAmount, payout2.OriginalAmount);
Assert.Equal(payout.Id, payout2.Id);
Assert.Equal(destination, payout2.Destination);
Assert.Equal(PayoutState.AwaitingApproval, payout.State);
Assert.Equal("BTC-CHAIN", payout2.PayoutMethodId);
Assert.Equal("BTC", payout2.CryptoCode);
Assert.Null(payout.PaymentMethodAmount);
Assert.Equal("BTC", payout2.PayoutCurrency);
Assert.Null(payout.PayoutAmount);
TestLogs.LogInformation("Can't overdraft");
@ -1136,7 +1230,7 @@ namespace BTCPayServer.Tests
Amount = 12.3m,
StartsAt = start,
Currency = "BTC",
PaymentMethods = new[] { "BTC" }
PayoutMethods = new[] { "BTC" }
});
Assert.Equal(start, inFuture.StartsAt);
Assert.Null(inFuture.ExpiresAt);
@ -1154,7 +1248,7 @@ namespace BTCPayServer.Tests
Amount = 12.3m,
ExpiresAt = expires,
Currency = "BTC",
PaymentMethods = new[] { "BTC" }
PayoutMethods = new[] { "BTC" }
});
await this.AssertAPIError("expired", async () => await unauthenticated.CreatePayout(inPast.Id, new CreatePayoutRequest()
{
@ -1178,7 +1272,7 @@ namespace BTCPayServer.Tests
Name = "Test USD",
Amount = 5000m,
Currency = "USD",
PaymentMethods = new[] { "BTC" }
PayoutMethods = new[] { "BTC" }
});
await this.AssertAPIError("lnurl-not-supported", async () => await unauthenticated.GetPullPaymentLNURL(pp.Id));
@ -1203,8 +1297,8 @@ namespace BTCPayServer.Tests
Revision = payout.Revision
});
Assert.Equal(PayoutState.AwaitingPayment, payout.State);
Assert.NotNull(payout.PaymentMethodAmount);
Assert.Equal(1.0m, payout.PaymentMethodAmount); // 1 BTC == 5000 USD in tests
Assert.NotNull(payout.PayoutAmount);
Assert.Equal(1.0m, payout.PayoutAmount); // 1 BTC == 5000 USD in tests
await this.AssertAPIError("invalid-state", async () => await client.ApprovePayout(storeId, payout.Id, new ApprovePayoutRequest()
{
Revision = payout.Revision
@ -1216,7 +1310,7 @@ namespace BTCPayServer.Tests
Name = "Test 2",
Amount = 12.303228134m,
Currency = "BTC",
PaymentMethods = new[] { "BTC" }
PayoutMethods = new[] { "BTC" }
});
destination = (await tester.ExplorerNode.GetNewAddressAsync()).ToString();
payout = await unauthenticated.CreatePayout(test3.Id, new CreatePayoutRequest()
@ -1226,8 +1320,8 @@ namespace BTCPayServer.Tests
});
payout = await client.ApprovePayout(storeId, payout.Id, new ApprovePayoutRequest());
// The payout should round the value of the payment down to the network of the payment method
Assert.Equal(12.30322814m, payout.PaymentMethodAmount);
Assert.Equal(12.303228134m, payout.Amount);
Assert.Equal(12.30322814m, payout.PayoutAmount);
Assert.Equal(12.303228134m, payout.OriginalAmount);
await client.MarkPayoutPaid(storeId, payout.Id);
payout = (await client.GetPayouts(payout.PullPaymentId)).First(data => data.Id == payout.Id);
@ -1240,7 +1334,7 @@ namespace BTCPayServer.Tests
Name = "Test 3",
Amount = 12.303228134m,
Currency = "BTC",
PaymentMethods = new[] { "BTC", "BTC-LightningNetwork", "BTC_LightningLike" }
PayoutMethods = new[] { "BTC", "BTC-LightningNetwork", "BTC_LightningLike" }
});
var lnrURLs = await unauthenticated.GetPullPaymentLNURL(test4.Id);
Assert.IsType<string>(lnrURLs.LNURLBech32);
@ -1315,7 +1409,7 @@ namespace BTCPayServer.Tests
Name = "Test SATS",
Amount = 21000,
Currency = "SATS",
PaymentMethods = new[] { "BTC", "BTC-LightningNetwork", "BTC_LightningLike" }
PayoutMethods = new[] { "BTC", "BTC-LightningNetwork", "BTC_LightningLike" }
});
lnrURLs = await unauthenticated.GetPullPaymentLNURL(testSats.Id);
Assert.IsType<string>(lnrURLs.LNURLBech32);
@ -1333,7 +1427,7 @@ namespace BTCPayServer.Tests
Amount = 100,
Currency = "USD",
Name = "pull payment",
PaymentMethods = new[] { "BTC" },
PayoutMethods = new[] { "BTC" },
AutoApproveClaims = true
});
});
@ -1353,7 +1447,7 @@ namespace BTCPayServer.Tests
Amount = 100,
Currency = "USD",
Name = "pull payment",
PaymentMethods = new[] { "BTC" },
PayoutMethods = new[] { "BTC" },
AutoApproveClaims = true
});
@ -1529,6 +1623,7 @@ namespace BTCPayServer.Tests
CssUrl = "https://example.org/style.css",
LogoUrl = "https://example.org/logo.svg",
BrandColor = "#003366",
ApplyBrandColorToBackend = true,
PaymentMethodCriteria = new List<PaymentMethodCriteriaData>
{
new()
@ -1544,6 +1639,7 @@ namespace BTCPayServer.Tests
Assert.Equal("https://example.org/style.css", updatedStore.CssUrl);
Assert.Equal("https://example.org/logo.svg", updatedStore.LogoUrl);
Assert.Equal("#003366", updatedStore.BrandColor);
Assert.True(updatedStore.ApplyBrandColorToBackend);
var s = (await client.GetStore(newStore.Id));
Assert.Equal("B", s.Name);
var pmc = Assert.Single(s.PaymentMethodCriteria);
@ -1711,7 +1807,7 @@ namespace BTCPayServer.Tests
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
var clientProfile = await user.CreateClient(Policies.CanModifyStoreWebhooks, Policies.CanCreateInvoice);
var clientProfile = await user.CreateClient(Policies.CanModifyWebhooks, Policies.CanCreateInvoice);
var hook = await clientProfile.CreateWebhook(user.StoreId, new CreateStoreWebhookRequest()
{
Url = fakeServer.ServerUri.AbsoluteUri,
@ -2616,6 +2712,14 @@ namespace BTCPayServer.Tests
invoiceObject = await client.GetOnChainWalletObject(user.StoreId, "BTC", new OnChainWalletObjectId("invoice", invoice.Id), false);
Assert.DoesNotContain(invoiceObject.Links.Select(l => l.Type), t => t == "address");
// Check if we can get the monitored invoice
var invoiceRepo = tester.PayTester.GetService<InvoiceRepository>();
var includeNonActivated = true;
Assert.Single(await invoiceRepo.GetMonitoredInvoices(PaymentMethodId.Parse("BTC-CHAIN"), includeNonActivated), i => i.Id == invoice.Id);
includeNonActivated = false;
Assert.DoesNotContain(await invoiceRepo.GetMonitoredInvoices(PaymentMethodId.Parse("BTC-CHAIN"), includeNonActivated), i => i.Id == invoice.Id);
Assert.DoesNotContain(await invoiceRepo.GetMonitoredInvoices(PaymentMethodId.Parse("BTC-CHAIN")), i => i.Id == invoice.Id);
//
paymentMethods = await client.GetInvoicePaymentMethods(store.Id, invoice.Id);
Assert.Single(paymentMethods);
@ -3105,6 +3209,8 @@ namespace BTCPayServer.Tests
});
Assert.Equal(firstAddress, (await viewOnlyClient.PreviewProposedStoreOnChainPaymentMethodAddresses(store.Id, "BTC", xpub)).Addresses.First().Address);
// Testing if the rewrite rule to old API path is working
await viewOnlyClient.SendHttpRequest($"api/v1/stores/{store.Id}/payment-methods/onchain/BTC/preview", new JObject() { ["config"] = xpub.ToString() }, HttpMethod.Post);
var method = await client.UpdateStorePaymentMethod(store.Id, "BTC-CHAIN", new UpdatePaymentMethodRequest() { Enabled = true, Config = JValue.CreateString(xpub.ToString())});
@ -3384,6 +3490,12 @@ namespace BTCPayServer.Tests
{
await viewOnlyClient.GetOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode);
});
// Testing if the rewrite rule to old API path is working
await AssertHttpError(403, async () =>
{
await viewOnlyClient.SendHttpRequest($"api/v1/stores/{walletId.StoreId}/payment-methods/onchain/{walletId.CryptoCode}/wallet/address", null as object);
});
var address = await client.GetOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode);
var address2 = await client.GetOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode);
var address3 = await client.GetOnChainWalletReceiveAddress(walletId.StoreId, walletId.CryptoCode, true);
@ -4010,7 +4122,12 @@ namespace BTCPayServer.Tests
var resp = await tester.CustomerLightningD.Pay(inv.BOLT11);
Assert.Equal(PayResult.Ok, resp.Result);
var store = tester.PayTester.GetService<StoreRepository>();
Assert.True(await store.InternalNodePayoutAuthorized(admin.StoreId));
Assert.False(await store.InternalNodePayoutAuthorized("blah"));
await admin.MakeAdmin(false);
Assert.False(await store.InternalNodePayoutAuthorized(admin.StoreId));
await admin.MakeAdmin(true);
var customerInvoice = await tester.CustomerLightningD.CreateInvoice(LightMoney.FromUnit(10, LightMoneyUnit.Satoshi),
Guid.NewGuid().ToString(), TimeSpan.FromDays(40));
@ -4071,7 +4188,7 @@ namespace BTCPayServer.Tests
PayoutMethodId = "BTC_LightningNetwork",
Destination = customerInvoice.BOLT11
});
Assert.Equal(payout2.Amount, new Money(100, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC));
Assert.Equal(payout2.OriginalAmount, new Money(100, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC));
}
[Fact(Timeout = 60 * 2 * 1000)]
@ -4115,7 +4232,7 @@ namespace BTCPayServer.Tests
Amount = 100,
Currency = "USD",
Name = "pull payment",
PaymentMethods = new[] { "BTC" }
PayoutMethods = new[] { "BTC" }
});
var notapprovedPayoutWithPullPayment = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest()
@ -4141,7 +4258,7 @@ namespace BTCPayServer.Tests
Assert.Equal(3, payouts.Length);
Assert.Empty(payouts.Where(data => data.State == PayoutState.AwaitingApproval));
Assert.Empty(payouts.Where(data => data.PaymentMethodAmount is null));
Assert.Empty(payouts.Where(data => data.PayoutAmount is null));
Assert.Empty(await adminClient.ShowOnChainWalletTransactions(admin.StoreId, "BTC"));
@ -4154,12 +4271,12 @@ namespace BTCPayServer.Tests
Assert.Equal(3600, Assert.Single(await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC")).IntervalSeconds.TotalSeconds);
var tpGen = Assert.Single(await adminClient.GetPayoutProcessors(admin.StoreId));
Assert.Equal("BTC-CHAIN", Assert.Single(tpGen.PaymentMethods));
Assert.Equal("BTC-CHAIN", Assert.Single(tpGen.PayoutMethods));
//still too poor to process any payouts
Assert.Empty(await adminClient.ShowOnChainWalletTransactions(admin.StoreId, "BTC"));
await adminClient.RemovePayoutProcessor(admin.StoreId, tpGen.Name, tpGen.PaymentMethods.First());
await adminClient.RemovePayoutProcessor(admin.StoreId, tpGen.Name, tpGen.PayoutMethods.First());
Assert.Empty(await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC"));
Assert.Empty(await adminClient.GetPayoutProcessors(admin.StoreId));

@ -50,10 +50,8 @@ namespace BTCPayServer.Tests
tester.Driver.FindElement(By.Name("Name")).SendKeys("English (Custom)");
tester.ClickPagePrimary();
var translations = tester.Driver.FindElement(By.Name("Translations"));
var text = translations.Text;
text = text.Replace("Password => Password", "Password => Mot de passe");
translations.Clear();
translations.SendKeys("Password => Mot de passe");
translations.SendKeys("{ \"Password\": \"Mot de passe\" }");
tester.ClickPagePrimary();
// Check English (Custom) can be selected
@ -64,7 +62,7 @@ namespace BTCPayServer.Tests
// Check if we can remove English (Custom)
tester.LogIn();
tester.GoToServer(Views.Server.ServerNavPages.Translations);
text = tester.Driver.PageSource;
var text = tester.Driver.PageSource;
Assert.Contains("Select-Cypherpunk", text);
Assert.DoesNotContain("Select-English (Custom)", text);
// Cypherpunk is loaded from file, can't edit

@ -55,6 +55,7 @@ namespace BTCPayServer.Tests
options.AddArguments($"window-size={windowSize.Width}x{windowSize.Height}");
options.AddArgument("shm-size=2g");
options.AddArgument("start-maximized");
options.AddArgument("disable-search-engine-choice-screen");
if (Server.PayTester.InContainer)
{
// Shot in the dark to fix https://stackoverflow.com/questions/53902507/unknown-error-session-deleted-because-of-page-crash-from-unknown-error-cannot
@ -546,7 +547,6 @@ retry:
{
walletId ??= WalletId;
GoToWallet(walletId, WalletsNavPages.Receive);
Driver.FindElement(By.Id("generateButton")).Click();
var addressStr = Driver.FindElement(By.Id("Address")).GetAttribute("data-text");
var address = BitcoinAddress.Create(addressStr, ((BTCPayNetwork)Server.NetworkProvider.GetNetwork(walletId.CryptoCode)).NBitcoinNetwork);
for (var i = 0; i < coins; i++)

@ -19,6 +19,7 @@ using BTCPayServer.NTag424;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Wallets;
using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server;
@ -371,7 +372,7 @@ namespace BTCPayServer.Tests
var usr = RandomUtils.GetUInt256().ToString().Substring(64 - 20) + "@a.com";
s.Driver.FindElement(By.Id("Email")).SendKeys(usr);
s.ClickPagePrimary();
var url = s.FindAlertMessage().FindElement(By.TagName("a")).Text;
var url = s.Driver.FindElement(By.Id("InvitationUrl")).GetAttribute("data-text");
s.Logout();
s.Driver.Navigate().GoToUrl(url);
@ -402,6 +403,84 @@ namespace BTCPayServer.Tests
Assert.Contains("/login", s.Driver.Url);
}
[Fact(Timeout = TestTimeout)]
public async Task CanManageUsers()
{
using var s = CreateSeleniumTester();
await s.StartAsync();
//Create Users
s.RegisterNewUser();
var user = s.AsTestAccount();
s.Logout();
s.GoToRegister();
s.RegisterNewUser(true);
var admin = s.AsTestAccount();
s.GoToHome();
s.GoToServer(ServerNavPages.Users);
// Manage user password reset
var rows = s.Driver.FindElements(By.CssSelector("#UsersList tr.user-overview-row"));
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(user.RegisterDetails.Email);
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr.user-overview-row"));
Assert.Single(rows);
Assert.Contains(user.RegisterDetails.Email, rows.First().Text);
s.Driver.FindElement(By.CssSelector("#UsersList tr.user-overview-row:first-child .reset-password")).Click();
s.Driver.WaitForElement(By.Id("Password")).SendKeys("Password@1!");
s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("Password@1!");
s.ClickPagePrimary();
Assert.Contains("Password successfully set", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success).Text);
// Manage user status (disable and enable)
// Disable user
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(user.RegisterDetails.Email);
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr.user-overview-row"));
Assert.Single(rows);
Assert.Contains(user.RegisterDetails.Email, rows.First().Text);
s.Driver.FindElement(By.CssSelector("#UsersList tr.user-overview-row:first-child .disable-user")).Click();
s.Driver.WaitForElement(By.Id("ConfirmContinue")).Click();
Assert.Contains("User disabled", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success).Text);
//Enable user
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(user.RegisterDetails.Email);
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr.user-overview-row"));
Assert.Single(rows);
Assert.Contains(user.RegisterDetails.Email, rows.First().Text);
s.Driver.FindElement(By.CssSelector("#UsersList tr.user-overview-row:first-child .enable-user")).Click();
s.Driver.WaitForElement(By.Id("ConfirmContinue")).Click();
Assert.Contains("User enabled", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success).Text);
// Manage user details (edit)
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(user.RegisterDetails.Email);
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr.user-overview-row"));
Assert.Single(rows);
Assert.Contains(user.RegisterDetails.Email, rows.First().Text);
s.Driver.FindElement(By.CssSelector("#UsersList tr.user-overview-row:first-child .user-edit")).Click();
s.Driver.WaitForElement(By.Id("Name")).SendKeys("Test User");
s.ClickPagePrimary();
Assert.Contains("User successfully updated", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success).Text);
// Manage user deletion
s.GoToServer(ServerNavPages.Users);
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(user.RegisterDetails.Email);
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr.user-overview-row"));
Assert.Single(rows);
Assert.Contains(user.RegisterDetails.Email, rows.First().Text);
s.Driver.FindElement(By.CssSelector("#UsersList tr.user-overview-row:first-child .delete-user")).Click();
s.Driver.WaitForElement(By.Id("ConfirmContinue")).Click();
Assert.Contains("User deleted", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success).Text);
s.Driver.AssertNoError();
}
[Fact(Timeout = TestTimeout)]
public async Task CanRequireApprovalForNewAccounts()
{
@ -745,6 +824,14 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.CssSelector("[data-invoice-state-badge] .dropdown-toggle")).Click();
s.Driver.FindElements(By.CssSelector("[data-invoice-state-badge] .dropdown-menu button"))[0].Click();
TestUtils.Eventually(() => Assert.Contains("Settled (marked)", s.Driver.PageSource));
// zero amount invoice should redirect to receipt
var zeroAmountId = s.CreateInvoice(0);
s.GoToUrl($"/i/{zeroAmountId}");
Assert.EndsWith("/receipt", s.Driver.Url);
Assert.Contains("$0.00", s.Driver.PageSource);
s.GoToInvoice(zeroAmountId);
Assert.Equal("Settled", s.Driver.FindElement(By.CssSelector("[data-invoice-state-badge]")).Text);
}
[Fact(Timeout = TestTimeout)]
@ -1544,7 +1631,6 @@ namespace BTCPayServer.Tests
s.GenerateWallet("BTC", "", false, true);
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")).GetAttribute("data-text");
var address = BitcoinAddress.Create(addressStr,
((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork);
@ -1749,7 +1835,6 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("WalletNav-Receive")).Click();
//generate a receiving address
s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).Click();
Assert.True(s.Driver.FindElement(By.CssSelector("#address-tab .qr-container")).Displayed);
// no previous page in the wizard, hence no back button
Assert.True(s.Driver.ElementDoesNotExist(By.Id("GoBack")));
@ -1771,10 +1856,6 @@ namespace BTCPayServer.Tests
Assert.NotNull(s.Driver.FindElement(By.CssSelector("[data-value='test-label']")));
});
//unreserve
s.Driver.FindElement(By.CssSelector("button[value=unreserve-current-address]")).Click();
//generate it again, should be the same one as before as nothing got used in the meantime
s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).Click();
Assert.True(s.Driver.FindElement(By.CssSelector("#address-tab .qr-container")).Displayed);
Assert.Equal(receiveAddr, s.Driver.FindElement(By.Id("Address")).GetAttribute("data-text"));
TestUtils.Eventually(() =>
@ -1864,7 +1945,7 @@ namespace BTCPayServer.Tests
// Check the tx sent earlier arrived
s.Driver.FindElement(By.Id($"StoreNav-Wallet{cryptoCode}")).Click();
s.Driver.WaitWalletTransactionsLoaded();
s.Driver.FindElement(By.PartialLinkText(tx.ToString()));
s.Driver.FindElement(By.CssSelector($"[data-text='{tx}']"));
var walletTransactionUri = new Uri(s.Driver.Url);
@ -1892,13 +1973,13 @@ namespace BTCPayServer.Tests
var jack = new Key().PubKey.Hash.GetAddress(Network.RegTest);
SetTransactionOutput(s, 0, jack, 0.01m);
s.Driver.FindElement(By.Id("SignTransaction")).Click();
s.Driver.WaitForElement(By.CssSelector("button[value=broadcast]"));
Assert.Contains(jack.ToString(), s.Driver.PageSource);
Assert.Contains("0.01000000", s.Driver.PageSource);
Assert.EndsWith("psbt/ready", s.Driver.Url);
s.Driver.FindElement(By.CssSelector("button[value=broadcast]")).Click();
Assert.Equal(walletTransactionUri.ToString(), s.Driver.Url);
var bip21 = invoice.EntityToDTO(s.Server.PayTester.GetService<Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension>>()).CryptoInfo.First().PaymentUrls.BIP21;
var bip21 = invoice.EntityToDTO(s.Server.PayTester.GetService<Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension>>(), s.Server.PayTester.GetService<CurrencyNameTable>()).CryptoInfo.First().PaymentUrls.BIP21;
//let's make bip21 more interesting
bip21 += "&label=Solid Snake&message=Snake? Snake? SNAAAAKE!";
var parsedBip21 = new BitcoinUrlBuilder(bip21, Network.RegTest);
@ -2682,9 +2763,9 @@ namespace BTCPayServer.Tests
var sums = cartData.FindElements(By.CssSelector("tfoot tr"));
Assert.Equal(2, items.Count);
Assert.Equal(4, sums.Count);
Assert.Contains("Manual entry 1", items[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("Custom Amount 1", items[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 234,00 €", items[0].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Manual entry 2", items[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("Custom Amount 2", items[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("0,56 €", items[1].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Subtotal", sums[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 234,56 €", sums[0].FindElement(By.CssSelector("td")).Text);
@ -2705,9 +2786,9 @@ namespace BTCPayServer.Tests
sums = paymentDetails.FindElements(By.CssSelector("tr.sums-data"));
Assert.Equal(2, items.Count);
Assert.Equal(4, sums.Count);
Assert.Contains("Manual entry 1", items[0].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("Custom Amount 1", items[0].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1 234,00 €", items[0].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Manual entry 2", items[1].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("Custom Amount 2", items[1].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("0,56 €", items[1].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Subtotal", sums[0].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1 234,56 €", sums[0].FindElement(By.CssSelector(".val")).Text);
@ -2768,7 +2849,7 @@ namespace BTCPayServer.Tests
Assert.Contains("1 x 1,00 € = 1,00 €", items[0].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Green Tea", items[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("2 x 1,00 € = 2,00 €", items[1].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Manual entry 1", items[2].FindElement(By.CssSelector("th")).Text);
Assert.Contains("Custom Amount 1", items[2].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1,23 €", items[2].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Total", sums[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("4,23 €", sums[0].FindElement(By.CssSelector("td")).Text);
@ -2787,7 +2868,7 @@ namespace BTCPayServer.Tests
Assert.Contains("1 x 1,00 € = 1,00 €", items[0].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Green Tea", items[1].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("2 x 1,00 € = 2,00 €", items[1].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Manual entry 1", items[2].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("Custom Amount 1", items[2].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1,23 €", items[2].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Total", sums[0].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("4,23 €", sums[0].FindElement(By.CssSelector(".val")).Text);
@ -3012,7 +3093,7 @@ namespace BTCPayServer.Tests
// Topup Invoice test
var i = s.CreateInvoice(storeId, null, cryptoCode);
s.GoToInvoiceCheckout(i);
var lnurl = s.Driver.FindElement(By.CssSelector("#Lightning_BTC-LNURL .truncate-center-start")).Text;
var lnurl = s.Driver.FindElement(By.CssSelector("#Lightning_BTC-LNURL .truncate-center")).GetAttribute("data-text");
var parsed = LNURL.LNURL.Parse(lnurl, out var tag);
var fetchedReuqest =
Assert.IsType<LNURL.LNURLPayRequest>(await LNURL.LNURL.FetchInformation(parsed, new HttpClient()));
@ -3049,7 +3130,7 @@ namespace BTCPayServer.Tests
i = s.CreateInvoice(storeId, 0.0000001m, cryptoCode);
s.GoToInvoiceCheckout(i);
// BOLT11 is also displayed for standard invoice (not LNURL, even if it is available)
var bolt11 = s.Driver.FindElement(By.CssSelector("#Lightning_BTC-LN .truncate-center-start")).Text;
var bolt11 = s.Driver.FindElement(By.CssSelector("#Lightning_BTC-LN .truncate-center")).GetAttribute("data-text");
BOLT11PaymentRequest.Parse(bolt11, s.Server.ExplorerNode.Network);
var invoiceId = s.Driver.Url.Split('/').Last();
using (var resp = await s.Server.PayTester.HttpClient.GetAsync("BTC/lnurl/pay/i/" + invoiceId))
@ -3102,7 +3183,7 @@ namespace BTCPayServer.Tests
i = s.CreateInvoice(storeId, null, cryptoCode);
s.GoToInvoiceCheckout(i);
lnurl = s.Driver.FindElement(By.CssSelector("#Lightning_BTC-LNURL .truncate-center-start")).Text;
lnurl = s.Driver.FindElement(By.CssSelector("#Lightning_BTC-LNURL .truncate-center")).GetAttribute("data-text");
Assert.StartsWith("lnurlp", lnurl);
LNURL.LNURL.Parse(lnurl, out tag);
@ -3115,7 +3196,7 @@ namespace BTCPayServer.Tests
Assert.Contains($"{cryptoCode} Lightning settings successfully updated", s.FindAlertMessage().Text);
var invForPP = s.CreateInvoice(null, cryptoCode);
s.GoToInvoiceCheckout(invForPP);
lnurl = s.Driver.FindElement(By.CssSelector("#Lightning_BTC-LNURL .truncate-center-start")).Text;
lnurl = s.Driver.FindElement(By.CssSelector("#Lightning_BTC-LNURL .truncate-center")).GetAttribute("data-text");
LNURL.LNURL.Parse(lnurl, out tag);
// Check that pull payment has lightning option

@ -247,8 +247,6 @@ namespace BTCPayServer.Tests
get; set;
}
readonly HttpClient _Http = new HttpClient();
public BTCPayServerTester PayTester
{
get; set;

@ -527,7 +527,7 @@ retry:
{
var server = new FakeServer();
await server.Start();
var client = await CreateClient(Policies.CanModifyStoreWebhooks);
var client = await CreateClient(Policies.CanModifyWebhooks);
var wh = await client.CreateWebhook(StoreId, new CreateStoreWebhookRequest()
{
AutomaticRedelivery = false,
@ -698,11 +698,13 @@ retry:
await writer.FlushAsync();
}
isHeader = true;
using (var writer = db.BeginTextImport("COPY \"Payments\" (\"Id\",\"Blob\",\"InvoiceDataId\",\"Accounted\",\"Blob2\",\"Type\") FROM STDIN DELIMITER ',' CSV HEADER"))
using (var writer = db.BeginTextImport("COPY \"Payments\" (\"Id\",\"Blob\",\"InvoiceDataId\",\"Accounted\",\"Blob2\",\"PaymentMethodId\") FROM STDIN DELIMITER ',' CSV HEADER"))
{
foreach (var invoice in oldPayments)
{
var localPayment = invoice.Replace("3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd", storeId);
// Old data could have Type to null.
localPayment += "UNKNOWN";
await writer.WriteLineAsync(localPayment);
}
await writer.FlushAsync();

@ -604,7 +604,7 @@
},
"expectedProperties": {
"Created": "04/23/2019 18:27:56 +00:00",
"Type": "BTC-CHAIN",
"PaymentMethodId": "BTC-CHAIN",
"Currency": "BTC",
"Status": "Settled",
"Amount": "0.07299962",
@ -634,7 +634,7 @@
},
"expectedProperties": {
"Created": "10/01/2018 14:13:22 +00:00",
"Type": "BTC-CHAIN",
"PaymentMethodId": "BTC-CHAIN",
"Currency": "BTC",
"Status": "Settled",
"Amount": "0.00017863",
@ -666,7 +666,7 @@
"Created": "03/21/2024 07:24:35 +00:00",
"CreatedInMs": "1711005875969",
"Amount": "0.00000001",
"Type": "BTC-LNURL",
"PaymentMethodId": "BTC-LNURL",
"Currency": "BTC"
}
},
@ -697,7 +697,7 @@
"Created": "03/20/2024 22:39:08 +00:00",
"CreatedInMs": "1710974348741",
"Amount": "0.00197864",
"Type": "BTC-CHAIN",
"PaymentMethodId": "BTC-CHAIN",
"Currency": "BTC",
"Status": "Settled",
"Accounted": null

@ -1463,7 +1463,7 @@ namespace BTCPayServer.Tests
{
Currency = "BTC",
Amount = 1.0m,
PaymentMethods = [ "BTC-CHAIN" ]
PayoutMethods = [ "BTC-CHAIN" ]
});
var controller = user.GetController<UIInvoiceController>();
var invoice = await controller.CreateInvoiceCoreRaw(new()
@ -1479,7 +1479,7 @@ namespace BTCPayServer.Tests
var payout = Assert.Single(payouts);
Assert.Equal("TOPUP", payout.PayoutMethodId);
Assert.Equal(invoice.Id, payout.Destination);
Assert.Equal(-0.5m, payout.Amount);
Assert.Equal(-0.5m, payout.OriginalAmount);
});
}
@ -2449,9 +2449,10 @@ namespace BTCPayServer.Tests
private static bool IsMapped(Invoice invoice, ApplicationDbContext ctx)
{
var h = BitcoinAddress.Create(invoice.BitcoinAddress, Network.RegTest).ScriptPubKey.Hash.ToString();
var pmi = PaymentTypes.CHAIN.GetPaymentMethodId("BTC");
return (ctx.AddressInvoices.Where(i => i.InvoiceDataId == invoice.Id).ToArrayAsync().GetAwaiter()
.GetResult())
.Where(i => i.GetAddress() == h).Any();
.Where(i => i.Address == h && i.PaymentMethodId == pmi.ToString()).Any();
}
@ -2835,7 +2836,6 @@ namespace BTCPayServer.Tests
Assert.Equal("coingecko", b.PreferredExchange);
}
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanDoInvoiceMigrations()
@ -3157,7 +3157,15 @@ namespace BTCPayServer.Tests
var invoiceId = GetInvoiceId(resp);
await acc.PayOnChain(invoiceId);
app = await client.CreatePointOfSaleApp(acc.StoreId, new PointOfSaleAppRequest
// Quick unrelated test on GetMonitoredInvoices
var invoiceRepo = tester.PayTester.GetService<InvoiceRepository>();
var monitored = Assert.Single(await invoiceRepo.GetMonitoredInvoices(PaymentMethodId.Parse("BTC-CHAIN")), i => i.Id == invoiceId);
Assert.Single(monitored.Payments);
monitored = Assert.Single(await invoiceRepo.GetMonitoredInvoices(PaymentMethodId.Parse("BTC-CHAIN"), true), i => i.Id == invoiceId);
Assert.Single(monitored.Payments);
//
app = await client.CreatePointOfSaleApp(acc.StoreId, new PointOfSaleAppRequest
{
AppName = "Cart",
DefaultView = PosViewType.Cart,

@ -12,7 +12,6 @@ services:
args:
CONFIGURATION_NAME: Release
environment:
TESTS_EXPERIMENTALV2_CONFIRM: "true"
TESTS_BTCRPCCONNECTION: server=http://bitcoind:43782;ceiwHEbqWI83:DwubwWsoo3
TESTS_LTCRPCCONNECTION: server=http://litecoind:43782;ceiwHEbqWI83:DwubwWsoo3
TESTS_BTCNBXPLORERURL: http://nbxplorer:32838/

@ -12,7 +12,6 @@ services:
args:
CONFIGURATION_NAME: Release
environment:
TESTS_EXPERIMENTALV2_CONFIRM: "true"
TESTS_BTCRPCCONNECTION: server=http://bitcoind:43782;ceiwHEbqWI83:DwubwWsoo3
TESTS_BTCNBXPLORERURL: http://nbxplorer:32838/
TESTS_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=btcpayserver

@ -83,7 +83,7 @@ curl -s -k -X PUT -H 'Content-Type: application/json' \
# Fund Satoshis Steaks wallet
btcaddress_satoshis_steaks=$(curl -s -k -X GET -H 'Content-Type: application/json' \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_satoshis_steaks/payment-methods/onchain/BTC/wallet/address" | jq -r '.address')
"$API_BASE/stores/$store_id_satoshis_steaks/payment-methods/BTC-CHAIN/wallet/address" | jq -r '.address')
./docker-bitcoin-cli.sh sendtoaddress "$btcaddress_satoshis_steaks" 6.15 >/dev/null 2>&1
@ -167,7 +167,7 @@ printf "Nakamoto Nuggets Cart POS ID: %s\n" "$cart_app_id_nakamoto_nuggets"
# Fund Nakamoto Nuggets wallet
btcaddress_nakamoto_nuggets=$(curl -s -k -X GET -H 'Content-Type: application/json' \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_nakamoto_nuggets/payment-methods/onchain/BTC/wallet/address" | jq -r '.address')
"$API_BASE/stores/$store_id_nakamoto_nuggets/payment-methods/BTC-CHAIN/wallet/address" | jq -r '.address')
./docker-bitcoin-cli.sh sendtoaddress "$btcaddress_nakamoto_nuggets" 6.15 >/dev/null 2>&1

@ -1,4 +1,5 @@
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Apps;
namespace BTCPayServer.Components.AppSales;
@ -12,6 +13,6 @@ public class AppSalesViewModel
public string AppUrl { get; set; }
public string DataUrl { get; set; }
public long SalesCount { get; set; }
public IEnumerable<SalesStatsItem> Series { get; set; }
public IEnumerable<AppSalesStatsItem> Series { get; set; }
public bool InitialRendering { get; set; }
}

@ -1,4 +1,5 @@
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Apps;
namespace BTCPayServer.Components.AppTopItems;
@ -10,7 +11,7 @@ public class AppTopItemsViewModel
public string AppType { get; set; }
public string AppUrl { get; set; }
public string DataUrl { get; set; }
public List<ItemStats> Entries { get; set; }
public List<AppItemStats> Entries { get; set; }
public List<int> SalesCount { get; set; }
public bool InitialRendering { get; set; }
}

@ -1,22 +1,21 @@
@model BTCPayServer.Components.TruncateCenter.TruncateCenterViewModel
@{
var classes = string.IsNullOrEmpty(Model.Classes) ? string.Empty : Model.Classes.Trim();
var isTruncated = !string.IsNullOrEmpty(Model.Start) && !string.IsNullOrEmpty(Model.End);
@if (Model.Copy) classes += " truncate-center--copy";
@if (Model.Elastic) classes += " truncate-center--elastic";
var prefix = Model.IsVue ? ":" : "";
}
<span class="truncate-center @classes" id="@Model.Id" data-text="@Model.Text">
<span class="truncate-center @classes" id="@Model.Id" @(prefix)data-text="@Model.Text">
@if (Model.IsVue)
{
<span class="truncate-center-truncated" data-bs-toggle="tooltip" :title="@Model.Text">
<span class="truncate-center-truncated" data-bs-toggle="tooltip" :title="@Model.Text">
@if (Model.Elastic)
{
<span class="truncate-center-start" v-text="@Model.Text"></span>
<span class="truncate-center-start" v-text="@(Model.Text).slice(0, @(Model.Text).length - @(Model.Padding))"></span>
}
else
{
<span class="truncate-center-start" v-text="@(Model.Text).length > 2 * @(Model.Padding) ? (@(Model.Text).slice(0, @(Model.Padding)) + '…') : @(Model.Text)"></span>
<span class="truncate-center-start" v-text="@(Model.Text).slice(0, @(Model.Padding)) + @(Model.Text).length > 2 * @(Model.Padding) ? '…' : ''"></span>
}
<span class="truncate-center-end" v-text="@(Model.Text).slice(-@(Model.Padding))" v-if="@(Model.Text).length > 2 * @(Model.Padding)"></span>
</span>
@ -24,12 +23,9 @@
}
else
{
<span class="truncate-center-truncated" @(isTruncated ? $"data-bs-toggle=tooltip title={Model.Text}" : "")>
<span class="truncate-center-start">@(Model.Elastic || !isTruncated ? Model.Text : $"{Model.Start}…")</span>
@if (isTruncated)
{
<span class="truncate-center-end">@Model.End</span>
}
<span class="truncate-center-truncated" @(Model.IsTruncated ? $"data-bs-toggle=tooltip title={Model.Text}" : "")>
<span class="truncate-center-start">@Model.Start</span>
<span class="truncate-center-end">@Model.End</span>
</span>
<span class="truncate-center-text">@Model.Text</span>
}

@ -19,6 +19,7 @@ public class TruncateCenter : ViewComponent
{
if (string.IsNullOrEmpty(text))
return new HtmlContentViewComponentResult(new StringHtmlContent(string.Empty));
var vm = new TruncateCenterViewModel
{
Classes = classes,
@ -28,12 +29,15 @@ public class TruncateCenter : ViewComponent
Copy = copy,
Text = text,
Link = link,
Id = id
Id = id,
IsTruncated = text.Length > 2 * padding
};
if (!isVue && text.Length > 2 * padding)
if (!vm.IsVue)
{
vm.Start = text[..padding];
vm.End = text[^padding..];
vm.Start = vm.IsTruncated ? text[..padding] : text;
vm.End = vm.IsTruncated ? text[^padding..] : string.Empty;
if (!vm.Elastic && vm.IsTruncated)
vm.Start = $"{vm.Start}…";
}
return View(vm);
}

@ -1,16 +1,17 @@
namespace BTCPayServer.Components.TruncateCenter
#nullable enable
namespace BTCPayServer.Components.TruncateCenter;
public class TruncateCenterViewModel
{
public class TruncateCenterViewModel
{
public string Text { get; set; }
public string Start { get; set; }
public string End { get; set; }
public string Id { get; set; }
public string Classes { get; set; }
public string Link { get; set; }
public int Padding { get; set; }
public bool Copy { get; set; }
public bool Elastic { get; set; }
public bool IsVue { get; set; }
}
public string Text { get; init; } = null!;
public string? Start { get; set; }
public string? End { get; set; }
public string? Id { get; init; }
public string? Classes { get; init; }
public string? Link { get; init; }
public int Padding { get; init; }
public bool Copy { get; init; }
public bool Elastic { get; init; }
public bool IsVue { get; init; }
public bool IsTruncated { get; init; }
}

@ -14,6 +14,7 @@ using BTCPayServer.Models;
using BTCPayServer.Payments;
using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NBitpayClient;
@ -27,14 +28,17 @@ namespace BTCPayServer.Controllers
{
private readonly UIInvoiceController _InvoiceController;
private readonly Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> _bitpayExtensions;
private readonly CurrencyNameTable _currencyNameTable;
private readonly InvoiceRepository _InvoiceRepository;
public BitpayInvoiceController(UIInvoiceController invoiceController,
Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> bitpayExtensions,
CurrencyNameTable currencyNameTable,
InvoiceRepository invoiceRepository)
{
_InvoiceController = invoiceController;
_bitpayExtensions = bitpayExtensions;
_currencyNameTable = currencyNameTable;
_InvoiceRepository = invoiceRepository;
}
@ -59,7 +63,7 @@ namespace BTCPayServer.Controllers
})).FirstOrDefault();
if (invoice == null)
throw new BitpayHttpException(404, "Object not found");
return new DataWrapper<InvoiceResponse>(invoice.EntityToDTO(_bitpayExtensions, Url));
return new DataWrapper<InvoiceResponse>(invoice.EntityToDTO(_bitpayExtensions, Url, _currencyNameTable));
}
[HttpGet]
[Route("invoices")]
@ -93,7 +97,7 @@ namespace BTCPayServer.Controllers
};
var entities = (await _InvoiceRepository.GetInvoices(query))
.Select((o) => o.EntityToDTO(_bitpayExtensions, Url)).ToArray();
.Select((o) => o.EntityToDTO(_bitpayExtensions, Url, _currencyNameTable)).ToArray();
return Json(DataWrapper.Create(entities));
}
@ -103,7 +107,7 @@ namespace BTCPayServer.Controllers
CancellationToken cancellationToken = default, Action<InvoiceEntity> entityManipulator = null)
{
var entity = await CreateInvoiceCoreRaw(invoice, store, serverUrl, additionalTags, cancellationToken, entityManipulator);
var resp = entity.EntityToDTO(_bitpayExtensions, Url);
var resp = entity.EntityToDTO(_bitpayExtensions, Url, _currencyNameTable);
return new DataWrapper<InvoiceResponse>(resp) { Facade = "pos/invoice" };
}

@ -226,6 +226,30 @@ namespace BTCPayServer.Controllers.Greenfield
return Ok();
}
[HttpGet("~/api/v1/apps/{appId}/sales")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetAppSales(string appId, [FromQuery] int numberOfDays = 7)
{
var app = await _appService.GetApp(appId, null, includeArchived: true);
if (app == null) return AppNotFound();
var stats = await _appService.GetSalesStats(app, numberOfDays);
return Ok(stats);
}
[HttpGet("~/api/v1/apps/{appId}/top-items")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetAppTopItems(string appId, [FromQuery] int offset = 0, [FromQuery] int count = 10)
{
var app = await _appService.GetApp(appId, null, includeArchived: true);
if (app == null) return AppNotFound();
var stats = (await _appService.GetItemStats(app)).ToList();
var max = Math.Min(count, stats.Count - offset);
var items = stats.GetRange(offset, max);
return Ok(items);
}
private IActionResult AppNotFound()
{
return this.CreateAPIError(404, "app-not-found", "The app with specified ID was not found");

@ -446,7 +446,7 @@ namespace BTCPayServer.Controllers.Greenfield
Name = request.Name ?? $"Refund {invoice.Id}",
Description = request.Description,
StoreId = storeId,
PayoutMethodIds = new[] { payoutMethodId },
PayoutMethods = new[] { payoutMethodId },
};
if (request.RefundVariant != RefundVariant.Custom)

@ -94,6 +94,8 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> GetNotificationSettings()
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
return NotFound();
var model = GetNotificationSettingsData(user);
return Ok(model);
}
@ -103,6 +105,8 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> UpdateNotificationSettings(UpdateNotificationSettingsRequest request)
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
return NotFound();
if (request.Disabled.Contains("all"))
{
user.DisabledNotifications = "all";

@ -36,7 +36,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
Name = factory.Processor,
FriendlyName = factory.FriendlyName,
PaymentMethods = factory.GetSupportedPayoutMethods().Select(id => id.ToString())
PayoutMethods = factory.GetSupportedPayoutMethods().Select(id => id.ToString())
.ToArray()
}));
}

@ -132,7 +132,7 @@ namespace BTCPayServer.Controllers.Greenfield
ModelState.AddModelError(nameof(request.BOLT11Expiration), $"The BOLT11 expiration should be positive");
}
PayoutMethodId?[]? payoutMethods = null;
if (request.PaymentMethods is { } payoutMethodsStr)
if (request.PayoutMethods is { } payoutMethodsStr)
{
payoutMethods = payoutMethodsStr.Select(s =>
{
@ -144,13 +144,13 @@ namespace BTCPayServer.Controllers.Greenfield
{
if (!supported.Contains(payoutMethods[i]))
{
request.AddModelError(paymentRequest => paymentRequest.PaymentMethods[i], "Invalid or unsupported payment method", this);
request.AddModelError(paymentRequest => paymentRequest.PayoutMethods[i], "Invalid or unsupported payment method", this);
}
}
}
else
{
ModelState.AddModelError(nameof(request.PaymentMethods), "This field is required");
ModelState.AddModelError(nameof(request.PayoutMethods), "This field is required");
}
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
@ -364,16 +364,17 @@ namespace BTCPayServer.Controllers.Greenfield
Id = p.Id,
PullPaymentId = p.PullPaymentDataId,
Date = p.Date,
Amount = p.OriginalAmount,
PaymentMethodAmount = p.Amount,
OriginalCurrency = p.OriginalCurrency,
OriginalAmount = p.OriginalAmount,
PayoutCurrency = p.Currency,
PayoutAmount = p.Amount,
Revision = blob.Revision,
State = p.State,
PayoutMethodId = p.PayoutMethodId,
PaymentProof = p.GetProofBlobJson(),
Destination = blob.Destination,
Metadata = blob.Metadata?? new JObject(),
};
model.Destination = blob.Destination;
model.PayoutMethodId = p.PayoutMethodId;
model.CryptoCode = p.Currency;
model.PaymentProof = p.GetProofBlobJson();
return model;
}

@ -28,7 +28,7 @@ public class GreenfieldServerRolesController : ControllerBase
[HttpGet("~/api/v1/server/roles")]
public async Task<IActionResult> GetServerRoles()
{
return Ok(FromModel(await _storeRepository.GetStoreRoles(null, false, false)));
return Ok(FromModel(await _storeRepository.GetStoreRoles(null, false)));
}
private List<RoleData> FromModel(StoreRepository.StoreRole[] data)
{

@ -46,7 +46,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
Stores = new[] { storeId },
Processors = new[] { LightningAutomatedPayoutSenderFactory.ProcessorName },
PayoutMethodIds = paymentMethodId is null ? null : new[] { paymentMethodId }
PayoutMethods = paymentMethodId is null ? null : new[] { paymentMethodId }
});
return Ok(configured.Select(ToModel).ToArray());
@ -88,7 +88,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
Stores = new[] { storeId },
Processors = new[] { LightningAutomatedPayoutSenderFactory.ProcessorName },
PayoutMethodIds = new[] { pmi }
PayoutMethods = new[] { pmi }
}))
.FirstOrDefault();
activeProcessor ??= new PayoutProcessorData();

@ -47,7 +47,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
Stores = new[] { storeId },
Processors = new[] { OnChainAutomatedPayoutSenderFactory.ProcessorName },
PayoutMethodIds = paymentMethodId is null ? null : new[] { paymentMethodId }
PayoutMethods = paymentMethodId is null ? null : new[] { paymentMethodId }
});
return Ok(configured.Select(ToModel).ToArray());
@ -94,7 +94,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
Stores = new[] { storeId },
Processors = new[] { OnChainAutomatedPayoutSenderFactory.ProcessorName },
PayoutMethodIds = new[] { payoutMethodId }
PayoutMethods = new[] { payoutMethodId }
}))
.FirstOrDefault();
activeProcessor ??= new PayoutProcessorData();

@ -19,7 +19,7 @@ namespace BTCPayServer.Controllers.Greenfield
public partial class GreenfieldStoreOnChainPaymentMethodsController
{
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/generate")]
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/generate")]
[EnableCors(CorsPolicies.All)]
public async Task<IActionResult> GenerateOnChainWallet(string storeId,
[ModelBinder(typeof(PaymentMethodIdModelBinder))]

@ -66,11 +66,11 @@ namespace BTCPayServer.Controllers.Greenfield
protected JsonHttpException ErrorPaymentMethodNotConfigured()
{
return new JsonHttpException(this.CreateAPIError(404, "paymentmethod-not-configured", "The lightning node is not set up"));
return new JsonHttpException(this.CreateAPIError(404, "paymentmethod-not-configured", "The payment method is not configured"));
}
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/preview")]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/preview")]
public IActionResult GetOnChainPaymentMethodPreview(
string storeId,
[ModelBinder(typeof(PaymentMethodIdModelBinder))]
@ -87,7 +87,7 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/preview")]
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/preview")]
public async Task<IActionResult> GetProposedOnChainPaymentMethodPreview(
string storeId,
[ModelBinder(typeof(PaymentMethodIdModelBinder))]

@ -95,10 +95,10 @@ namespace BTCPayServer.Controllers.Greenfield
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet")]
public async Task<IActionResult> ShowOnChainWalletOverview(string storeId, string cryptoCode)
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet")]
public async Task<IActionResult> ShowOnChainWalletOverview(string storeId, string paymentMethodId)
{
if (IsInvalidWalletRequest(cryptoCode, out var network,
if (IsInvalidWalletRequest(paymentMethodId, out var network,
out var derivationScheme, out var actionResult))
return actionResult;
@ -115,10 +115,10 @@ namespace BTCPayServer.Controllers.Greenfield
}
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/feerate")]
public async Task<IActionResult> GetOnChainFeeRate(string storeId, string cryptoCode, int? blockTarget = null)
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/feerate")]
public async Task<IActionResult> GetOnChainFeeRate(string storeId, string paymentMethodId, int? blockTarget = null)
{
if (IsInvalidWalletRequest(cryptoCode, out var network,
if (IsInvalidWalletRequest(paymentMethodId, out var network,
out _, out var actionResult))
return actionResult;
@ -131,15 +131,15 @@ namespace BTCPayServer.Controllers.Greenfield
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/address")]
public async Task<IActionResult> GetOnChainWalletReceiveAddress(string storeId, string cryptoCode,
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/address")]
public async Task<IActionResult> GetOnChainWalletReceiveAddress(string storeId, string paymentMethodId,
bool forceGenerate = false)
{
if (IsInvalidWalletRequest(cryptoCode, out var network,
if (IsInvalidWalletRequest(paymentMethodId, out var network,
out var derivationScheme, out var actionResult))
return actionResult;
var kpi = await _walletReceiveService.GetOrGenerate(new WalletId(storeId, cryptoCode), forceGenerate);
var kpi = await _walletReceiveService.GetOrGenerate(new WalletId(storeId, network.CryptoCode), forceGenerate);
if (kpi is null)
{
return BadRequest();
@ -151,7 +151,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
bip21.QueryParams.Add(PayjoinClient.BIP21EndpointKey,
Request.GetAbsoluteUri(Url.Action(nameof(PayJoinEndpointController.Submit), "PayJoinEndpoint",
new { cryptoCode })));
new { network.CryptoCode })));
}
return Ok(new OnChainWalletAddressData()
@ -163,28 +163,28 @@ namespace BTCPayServer.Controllers.Greenfield
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpDelete("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/address")]
public async Task<IActionResult> UnReserveOnChainWalletReceiveAddress(string storeId, string cryptoCode)
[HttpDelete("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/address")]
public async Task<IActionResult> UnReserveOnChainWalletReceiveAddress(string storeId, string paymentMethodId)
{
if (IsInvalidWalletRequest(cryptoCode, out _,
if (IsInvalidWalletRequest(paymentMethodId, out var network,
out _, out var actionResult))
return actionResult;
var addr = await _walletReceiveService.UnReserveAddress(new WalletId(storeId, cryptoCode));
var addr = await _walletReceiveService.UnReserveAddress(new WalletId(storeId, network.CryptoCode));
if (addr is null)
{
return this.CreateAPIError("no-reserved-address",
$"There was no reserved address for {cryptoCode} on this store.");
$"There was no reserved address for {network.CryptoCode} on this store.");
}
return Ok();
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions")]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/transactions")]
public async Task<IActionResult> ShowOnChainWalletTransactions(
string storeId,
string cryptoCode,
string paymentMethodId,
[FromQuery] TransactionStatus[]? statusFilter = null,
[FromQuery] string? labelFilter = null,
[FromQuery] int skip = 0,
@ -192,12 +192,12 @@ namespace BTCPayServer.Controllers.Greenfield
CancellationToken cancellationToken = default
)
{
if (IsInvalidWalletRequest(cryptoCode, out var network,
if (IsInvalidWalletRequest(paymentMethodId, out var network,
out var derivationScheme, out var actionResult))
return actionResult;
var wallet = _btcPayWalletProvider.GetWallet(network);
var walletId = new WalletId(storeId, cryptoCode);
var walletId = new WalletId(storeId, network.CryptoCode);
var walletTransactionsInfoAsync = await _walletRepository.GetWalletTransactionsInfo(walletId, (string[]?)null);
var preFiltering = true;
@ -238,11 +238,11 @@ namespace BTCPayServer.Controllers.Greenfield
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions/{transactionId}")]
public async Task<IActionResult> GetOnChainWalletTransaction(string storeId, string cryptoCode,
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/transactions/{transactionId}")]
public async Task<IActionResult> GetOnChainWalletTransaction(string storeId, string paymentMethodId,
string transactionId)
{
if (IsInvalidWalletRequest(cryptoCode, out var network,
if (IsInvalidWalletRequest(paymentMethodId, out var network,
out var derivationScheme, out var actionResult))
return actionResult;
@ -253,7 +253,7 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateAPIError(404, "transaction-not-found", "The transaction was not found.");
}
var walletId = new WalletId(storeId, cryptoCode);
var walletId = new WalletId(storeId, network.CryptoCode);
var walletTransactionsInfoAsync =
(await _walletRepository.GetWalletTransactionsInfo(walletId, new[] { transactionId })).Values
.FirstOrDefault();
@ -263,16 +263,16 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPatch(
"~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions/{transactionId}")]
"~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/transactions/{transactionId}")]
public async Task<IActionResult> PatchOnChainWalletTransaction(
string storeId,
string cryptoCode,
string paymentMethodId,
string transactionId,
[FromBody] PatchOnChainTransactionRequest request,
bool force = false
)
{
if (IsInvalidWalletRequest(cryptoCode, out var network,
if (IsInvalidWalletRequest(paymentMethodId, out var network,
out var derivationScheme, out var actionResult))
return actionResult;
@ -283,7 +283,7 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateAPIError(404, "transaction-not-found", "The transaction was not found.");
}
var walletId = new WalletId(storeId, cryptoCode);
var walletId = new WalletId(storeId, network.CryptoCode);
var txObjectId = new WalletObjectId(walletId, WalletObjectData.Types.Tx, transactionId);
if (request.Comment != null)
@ -305,20 +305,20 @@ namespace BTCPayServer.Controllers.Greenfield
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/utxos")]
public async Task<IActionResult> GetOnChainWalletUTXOs(string storeId, string cryptoCode)
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/utxos")]
public async Task<IActionResult> GetOnChainWalletUTXOs(string storeId, string paymentMethodId)
{
if (IsInvalidWalletRequest(cryptoCode, out var network,
if (IsInvalidWalletRequest(paymentMethodId, out var network,
out var derivationScheme, out var actionResult))
return actionResult;
var wallet = _btcPayWalletProvider.GetWallet(network);
var walletId = new WalletId(storeId, cryptoCode);
var walletId = new WalletId(storeId, network.CryptoCode);
var utxos = await wallet.GetUnspentCoins(derivationScheme.AccountDerivation);
var walletTransactionsInfoAsync = await _walletRepository.GetWalletTransactionsInfo(walletId,
utxos.SelectMany(GetWalletObjectsQuery.Get).Distinct().ToArray());
var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode);
var pmi = PaymentMethodId.Parse(paymentMethodId);
return Ok(utxos.Select(coin =>
{
walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info1);
@ -347,17 +347,17 @@ namespace BTCPayServer.Controllers.Greenfield
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions")]
public async Task<IActionResult> CreateOnChainTransaction(string storeId, string cryptoCode,
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/transactions")]
public async Task<IActionResult> CreateOnChainTransaction(string storeId, string paymentMethodId,
[FromBody] CreateOnChainTransactionRequest request)
{
if (IsInvalidWalletRequest(cryptoCode, out var network,
if (IsInvalidWalletRequest(paymentMethodId, out var network,
out var derivationScheme, out var actionResult))
return actionResult;
if (network.ReadonlyWallet)
{
return this.CreateAPIError(503, "not-available",
$"{cryptoCode} sending services are not currently available");
$"{network.CryptoCode} sending services are not currently available");
}
//This API is only meant for hot wallet usage for now. We can expand later when we allow PSBT manipulation.
@ -387,7 +387,7 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateValidationError(ModelState);
}
var explorerClient = _explorerClientProvider.GetExplorerClient(cryptoCode);
var explorerClient = _explorerClientProvider.GetExplorerClient(network);
var wallet = _btcPayWalletProvider.GetWallet(network);
var utxos = await wallet.GetUnspentCoins(derivationScheme.AccountDerivation, request.ExcludeUnconfirmed);
@ -552,7 +552,7 @@ namespace BTCPayServer.Controllers.Greenfield
if (!derivationScheme.IsHotWallet || signingKeyStr is null)
{
return this.CreateAPIError(503, "not-available",
$"{cryptoCode} sending services are not currently available");
$"{network.CryptoCode} sending services are not currently available");
}
var signingKey = ExtKey.Parse(signingKeyStr, network.NBitcoinNetwork);
@ -599,12 +599,12 @@ namespace BTCPayServer.Controllers.Greenfield
payjoinPSBT.Finalize();
var payjoinTransaction = payjoinPSBT.ExtractTransaction();
var hash = payjoinTransaction.GetHash();
await this._walletRepository.AddWalletTransactionAttachment(new WalletId(Store.Id, cryptoCode),
await this._walletRepository.AddWalletTransactionAttachment(new WalletId(Store.Id, network.CryptoCode),
hash, Attachment.Payjoin());
broadcastResult = await explorerClient.BroadcastAsync(payjoinTransaction);
if (broadcastResult.Success)
{
return await GetOnChainWalletTransaction(storeId, cryptoCode, hash.ToString());
return await GetOnChainWalletTransaction(storeId, paymentMethodId, hash.ToString());
}
}
catch (PayjoinException)
@ -621,7 +621,7 @@ namespace BTCPayServer.Controllers.Greenfield
broadcastResult = await explorerClient.BroadcastAsync(transaction);
if (broadcastResult.Success)
{
return await GetOnChainWalletTransaction(storeId, cryptoCode, transactionHash.ToString());
return await GetOnChainWalletTransaction(storeId, paymentMethodId, transactionHash.ToString());
}
else
{
@ -629,9 +629,9 @@ namespace BTCPayServer.Controllers.Greenfield
}
}
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects")]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/objects")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetOnChainWalletObjects(string storeId, string cryptoCode, string? type = null, [FromQuery(Name = "ids")] string[]? ids = null, bool? includeNeighbourData = null)
public async Task<IActionResult> GetOnChainWalletObjects(string storeId, string paymentMethodId, string? type = null, [FromQuery(Name = "ids")] string[]? ids = null, bool? includeNeighbourData = null)
{
if (ids?.Length is 0 && !Request.Query.ContainsKey("ids"))
ids = null;
@ -639,28 +639,34 @@ namespace BTCPayServer.Controllers.Greenfield
ModelState.AddModelError(nameof(ids), "If ids is specified, type should be specified");
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
var walletId = new WalletId(storeId, cryptoCode);
if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
return actionResult;
var walletId = new WalletId(storeId, network.CryptoCode);
return Ok((await _walletRepository.GetWalletObjects(new(walletId, type, ids) { IncludeNeighbours = includeNeighbourData ?? true })).Select(kv => kv.Value).Select(ToModel).ToArray());
}
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectType}/{objectId}")]
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/objects/{objectType}/{objectId}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetOnChainWalletObject(string storeId, string cryptoCode,
public async Task<IActionResult> GetOnChainWalletObject(string storeId, string paymentMethodId,
string objectType, string objectId,
bool? includeNeighbourData = null)
{
var walletId = new WalletId(storeId, cryptoCode);
if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
return actionResult;
var walletId = new WalletId(storeId, network.CryptoCode);
var wo = await _walletRepository.GetWalletObject(new(walletId, objectType, objectId), includeNeighbourData ?? true);
if (wo is null)
return WalletObjectNotFound();
return Ok(ToModel(wo));
}
[HttpDelete("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectType}/{objectId}")]
[HttpDelete("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/objects/{objectType}/{objectId}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> RemoveOnChainWalletObject(string storeId, string cryptoCode,
public async Task<IActionResult> RemoveOnChainWalletObject(string storeId, string paymentMethodId,
string objectType, string objectId)
{
var walletId = new WalletId(storeId, cryptoCode);
if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
return actionResult;
var walletId = new WalletId(storeId, network.CryptoCode);
if (await _walletRepository.RemoveWalletObjects(new WalletObjectId(walletId, objectType, objectId)))
return Ok();
else
@ -672,11 +678,14 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateAPIError(404, "wallet-object-not-found", "This wallet object's can't be found");
}
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects")]
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/objects")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> AddOrUpdateOnChainWalletObject(string storeId, string cryptoCode,
public async Task<IActionResult> AddOrUpdateOnChainWalletObject(string storeId,
string paymentMethodId,
[FromBody] AddOnChainWalletObjectRequest request)
{
if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
return actionResult;
if (request?.Type is null)
ModelState.AddModelError(nameof(request.Type), "Type is required");
if (request?.Id is null)
@ -684,13 +693,13 @@ namespace BTCPayServer.Controllers.Greenfield
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
var walletId = new WalletId(storeId, cryptoCode);
var walletId = new WalletId(storeId, network.CryptoCode);
try
{
await _walletRepository.SetWalletObject(
new WalletObjectId(walletId, request!.Type, request.Id), request.Data);
return await GetOnChainWalletObject(storeId, cryptoCode, request!.Type, request.Id);
return await GetOnChainWalletObject(storeId, network.CryptoCode, request!.Type, request.Id);
}
catch (DbUpdateException)
{
@ -698,12 +707,14 @@ namespace BTCPayServer.Controllers.Greenfield
}
}
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectType}/{objectId}/links")]
[HttpPost("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/objects/{objectType}/{objectId}/links")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> AddOrUpdateOnChainWalletLinks(string storeId, string cryptoCode,
public async Task<IActionResult> AddOrUpdateOnChainWalletLinks(string storeId, string paymentMethodId,
string objectType, string objectId,
[FromBody] AddOnChainWalletObjectLinkRequest request)
{
if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
return actionResult;
if (request?.Type is null)
ModelState.AddModelError(nameof(request.Type), "Type is required");
if (request?.Id is null)
@ -711,7 +722,7 @@ namespace BTCPayServer.Controllers.Greenfield
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
var walletId = new WalletId(storeId, cryptoCode);
var walletId = new WalletId(storeId, network.CryptoCode);
try
{
await _walletRepository.SetWalletObjectLink(
@ -726,13 +737,15 @@ namespace BTCPayServer.Controllers.Greenfield
}
}
[HttpDelete("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/objects/{objectType}/{objectId}/links/{linkType}/{linkId}")]
[HttpDelete("~/api/v1/stores/{storeId}/payment-methods/{paymentMethodId}/wallet/objects/{objectType}/{objectId}/links/{linkType}/{linkId}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> RemoveOnChainWalletLink(string storeId, string cryptoCode,
public async Task<IActionResult> RemoveOnChainWalletLink(string storeId, string paymentMethodId,
string objectType, string objectId,
string linkType, string linkId)
{
var walletId = new WalletId(storeId, cryptoCode);
if (IsInvalidWalletRequest(paymentMethodId, out var network, out var actionResult))
return actionResult;
var walletId = new WalletId(storeId, network.CryptoCode);
if (await _walletRepository.RemoveWalletObjectLink(
new WalletObjectId(walletId, objectType, objectId),
new WalletObjectId(walletId, linkType, linkId)))
@ -769,31 +782,19 @@ namespace BTCPayServer.Controllers.Greenfield
return await _authorizationService.CanUseHotWallet(PoliciesSettings, User);
}
private bool IsInvalidWalletRequest(string cryptoCode, [MaybeNullWhen(true)] out BTCPayNetwork network,
private bool IsInvalidWalletRequest(string paymentMethodId, [MaybeNullWhen(true)] out BTCPayNetwork network,
[MaybeNullWhen(true)] out DerivationSchemeSettings derivationScheme,
[MaybeNullWhen(false)] out IActionResult actionResult)
{
var pmi = PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode);
derivationScheme = null;
if (!_handlers.TryGetValue(pmi, out var handler))
{
throw new JsonHttpException(this.CreateAPIError(404, "unknown-cryptocode",
"This crypto code isn't set up in this BTCPay Server instance"));
}
network = ((IHasNetwork)handler).Network;
if (!network.WalletSupported || !_btcPayWalletProvider.IsAvailable(network))
{
actionResult = this.CreateAPIError(503, "not-available",
$"{cryptoCode} services are not currently available");
if (IsInvalidWalletRequest(paymentMethodId, out network, out actionResult))
return true;
}
derivationScheme = GetDerivationSchemeSettings(cryptoCode);
derivationScheme = GetDerivationSchemeSettings(network.CryptoCode);
if (derivationScheme?.AccountDerivation is null)
{
actionResult = this.CreateAPIError(503, "not-available",
$"{cryptoCode} doesn't have any derivation scheme set");
$"{network.CryptoCode} doesn't have any derivation scheme set");
return true;
}
@ -801,6 +802,28 @@ namespace BTCPayServer.Controllers.Greenfield
return false;
}
private bool IsInvalidWalletRequest(string paymentMethodId, [MaybeNullWhen(true)] out BTCPayNetwork network,
[MaybeNullWhen(false)] out IActionResult actionResult)
{
if (!PaymentMethodId.TryParse(paymentMethodId, out var pmi)
|| !_handlers.TryGetValue(pmi, out var handler)
|| handler is not IHasNetwork { Network: { WalletSupported: true } })
{
throw new JsonHttpException(this.CreateAPIError(404, "unknown-paymentMethodId",
"This payment method doesn't exists or doesn't offer wallet services"));
}
network = ((IHasNetwork)handler).Network;
if (!_btcPayWalletProvider.IsAvailable(network))
{
actionResult = this.CreateAPIError(503, "not-available",
$"{pmi} services are not currently available");
return true;
}
actionResult = null;
return false;
}
private DerivationSchemeSettings? GetDerivationSchemeSettings(string cryptoCode)
{
return Store.GetPaymentMethodConfig<DerivationSchemeSettings>(PaymentTypes.CHAIN.GetPaymentMethodId(cryptoCode), _handlers);

@ -39,7 +39,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
Name = datas.Key,
FriendlyName = _factories.FirstOrDefault(factory => factory.Processor == datas.Key)?.FriendlyName,
PaymentMethods = datas.Select(data => data.PayoutMethodId).ToArray()
PayoutMethods = datas.Select(data => data.PayoutMethodId).ToArray()
});
return Ok(configured);
@ -55,7 +55,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
Stores = new[] { storeId },
Processors = new[] { processor },
PayoutMethodIds = new[] { PayoutMethodId.Parse(paymentMethod) }
PayoutMethods = new[] { PayoutMethodId.Parse(paymentMethod) }
})).FirstOrDefault();
if (matched is null)
{

@ -31,7 +31,7 @@ namespace BTCPayServer.Controllers.Greenfield
var store = HttpContext.GetStoreData();
return store == null
? StoreNotFound()
: Ok(FromModel(await _storeRepository.GetStoreRoles(storeId, false, false)));
: Ok(FromModel(await _storeRepository.GetStoreRoles(storeId, false)));
}
private List<RoleData> FromModel(StoreRepository.StoreRole[] data)

@ -24,7 +24,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield,
Policy = Policies.CanModifyStoreWebhooks)]
Policy = Policies.CanModifyWebhooks)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldStoreWebhooksController : ControllerBase
{

@ -175,6 +175,7 @@ namespace BTCPayServer.Controllers.Greenfield
Website = data.StoreWebsite,
Archived = data.Archived,
BrandColor = storeBlob.BrandColor,
ApplyBrandColorToBackend = storeBlob.ApplyBrandColorToBackend,
CssUrl = storeBlob.CssUrl == null ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), storeBlob.CssUrl),
LogoUrl = storeBlob.LogoUrl == null ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), storeBlob.LogoUrl),
PaymentSoundUrl = storeBlob.PaymentSoundUrl == null ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), storeBlob.PaymentSoundUrl),
@ -255,6 +256,7 @@ namespace BTCPayServer.Controllers.Greenfield
blob.PaymentTolerance = restModel.PaymentTolerance;
blob.PayJoinEnabled = restModel.PayJoinEnabled;
blob.BrandColor = restModel.BrandColor;
blob.ApplyBrandColorToBackend = restModel.ApplyBrandColorToBackend;
blob.LogoUrl = restModel.LogoUrl is null ? null : UnresolvedUri.Create(restModel.LogoUrl);
blob.CssUrl = restModel.CssUrl is null ? null : UnresolvedUri.Create(restModel.CssUrl);
blob.PaymentSoundUrl = restModel.PaymentSoundUrl is null ? null : UnresolvedUri.Create(restModel.PaymentSoundUrl);

@ -14,6 +14,7 @@ using BTCPayServer.Controllers.GreenField;
using BTCPayServer.Data;
using BTCPayServer.Security;
using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
@ -1142,6 +1143,18 @@ namespace BTCPayServer.Controllers.Greenfield
await GetController<GreenfieldAppsController>().GetAllApps());
}
public override async Task<AppSalesStats> GetAppSales(string appId, int numberOfDays = 7, CancellationToken token = default)
{
return GetFromActionResult<AppSalesStats>(
await GetController<GreenfieldAppsController>().GetAppSales(appId, numberOfDays));
}
public override async Task<List<AppItemStats>> GetAppTopItems(string appId, int offset = 0, int count = 10, CancellationToken token = default)
{
return GetFromActionResult<List<AppItemStats>>(
await GetController<GreenfieldAppsController>().GetAppTopItems(appId, offset, count));
}
public override async Task DeleteApp(string appId, CancellationToken token = default)
{
HandleActionResult(await GetController<GreenfieldAppsController>().DeleteApp(appId));

@ -16,6 +16,7 @@ using BTCPayServer.Filters;
using BTCPayServer.Logging;
using BTCPayServer.Models.AccountViewModels;
using BTCPayServer.Services;
using BTCPayServer.Services.Mails;
using Fido2NetLib;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
@ -706,7 +707,7 @@ namespace BTCPayServer.Controllers
[HttpGet("/login/forgot-password")]
[AllowAnonymous]
public IActionResult ForgotPassword()
public ActionResult ForgotPassword()
{
return View();
}
@ -717,7 +718,8 @@ namespace BTCPayServer.Controllers
[RateLimitsFilter(ZoneLimits.ForgotPassword, Scope = RateLimitsScope.RemoteAddress)]
public async Task<IActionResult> ForgotPassword(ForgotPasswordViewModel model)
{
if (ModelState.IsValid)
var settings = await _SettingsRepository.GetSettingAsync<EmailSettings>();
if (ModelState.IsValid && settings?.IsComplete() is true)
{
var user = await _userManager.FindByEmailAsync(model.Email);
if (!UserService.TryCanLogin(user, out _))
@ -739,7 +741,7 @@ namespace BTCPayServer.Controllers
[HttpGet("/login/forgot-password/confirm")]
[AllowAnonymous]
public IActionResult ForgotPasswordConfirmation()
public ActionResult ForgotPasswordConfirmation()
{
return View();
}
@ -812,7 +814,7 @@ namespace BTCPayServer.Controllers
return NotFound();
}
var user = await _userManager.FindByInvitationTokenAsync(userId, Uri.UnescapeDataString(code));
var user = await _userManager.FindByInvitationTokenAsync<ApplicationUser>(userId, Uri.UnescapeDataString(code));
if (user == null)
{
return NotFound();
@ -827,6 +829,9 @@ namespace BTCPayServer.Controllers
RequestUri = Request.GetAbsoluteRootUri()
});
// unset used token
await _userManager.UnsetInvitationTokenAsync<ApplicationUser>(user.Id);
if (requiresEmailConfirmation)
{
return await RedirectToConfirmEmail(user);

@ -199,16 +199,13 @@ namespace BTCPayServer.Controllers
return NotFound();
var receipt = InvoiceDataBase.ReceiptOptions.Merge(store.GetStoreBlob().ReceiptOptions, i.ReceiptOptions);
if (receipt.Enabled is not true)
{
if (i.RedirectURL is not null)
{
return Redirect(i.RedirectURL.ToString());
}
return NotFound();
return i.RedirectURL is not null
? Redirect(i.RedirectURL.ToString())
: NotFound();
}
var storeBlob = store.GetStoreBlob();
var vm = new InvoiceReceiptViewModel
{
@ -416,7 +413,7 @@ namespace BTCPayServer.Controllers
createPullPayment = new CreatePullPayment
{
Name = $"Refund {invoice.Id}",
PayoutMethodIds = new[] { pmi },
PayoutMethods = new[] { pmi },
StoreId = invoice.StoreId,
BOLT11Expiration = store.GetStoreBlob().RefundBOLT11Expiration
};
@ -579,10 +576,10 @@ namespace BTCPayServer.Controllers
PaymentMethodRaw = data,
PaymentMethodId = paymentMethodId,
PaymentMethod = paymentMethodId.ToString(),
TotalDue = _displayFormatter.Currency(accounting.TotalDue, data.Currency),
Due = hasPayment ? _displayFormatter.Currency(accounting.Due, data.Currency) : null,
Paid = hasPayment ? _displayFormatter.Currency(accounting.PaymentMethodPaid, data.Currency) : null,
Overpaid = hasPayment ? _displayFormatter.Currency(overpaidAmount, data.Currency) : null,
TotalDue = _displayFormatter.Currency(accounting.TotalDue, data.Currency, divisibility: data.Divisibility),
Due = hasPayment ? _displayFormatter.Currency(accounting.Due, data.Currency, divisibility: data.Divisibility) : null,
Paid = hasPayment ? _displayFormatter.Currency(accounting.PaymentMethodPaid, data.Currency, divisibility: data.Divisibility) : null,
Overpaid = hasPayment ? _displayFormatter.Currency(overpaidAmount, data.Currency, divisibility: data.Divisibility) : null,
Address = data.Destination
};
}).ToList(),
@ -652,9 +649,7 @@ namespace BTCPayServer.Controllers
if (derivationScheme is null)
return NotSupported("This feature is only available to BTC wallets");
var btc = PaymentTypes.CHAIN.GetPaymentMethodId("BTC");
var bumpableAddresses = (await GetAddresses(selectedItems))
.Where(p => p.GetPaymentMethodId() == btc)
.Select(p => p.GetAddress()).ToHashSet();
var bumpableAddresses = await GetAddresses(btc, selectedItems);
var utxos = await explorer.GetUTXOsAsync(derivationScheme);
var bumpableUTXOs = utxos.GetUnspentUTXOs().Where(u => u.Confirmations == 0 && bumpableAddresses.Contains(u.ScriptPubKey.Hash.ToString())).ToArray();
var parameters = new MultiValueDictionary<string, string>();
@ -676,10 +671,10 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(ListInvoices), new { storeId });
}
private async Task<AddressInvoiceData[]> GetAddresses(string[] selectedItems)
private async Task<HashSet<string>> GetAddresses(PaymentMethodId paymentMethodId, string[] selectedItems)
{
using var ctx = _dbContextFactory.CreateContext();
return await ctx.AddressInvoices.Where(i => selectedItems.Contains(i.InvoiceDataId)).ToArrayAsync();
return new HashSet<string>(await ctx.AddressInvoices.Where(i => selectedItems.Contains(i.InvoiceDataId) && i.PaymentMethodId == paymentMethodId.ToString()).Select(i => i.Address).ToArrayAsync());
}
[HttpGet("i/{invoiceId}")]
@ -699,7 +694,16 @@ namespace BTCPayServer.Controllers
var model = await GetInvoiceModel(invoiceId, paymentMethodId == null ? null : PaymentMethodId.Parse(paymentMethodId), lang);
if (model == null)
return NotFound();
{
// see if the invoice actually exists and is in a state for which we do not display the checkout
var invoice = await _InvoiceRepository.GetInvoice(invoiceId);
var store = invoice != null ? await _StoreRepository.GetStoreByInvoiceId(invoice.Id) : null;
var receipt = invoice != null && store != null ? InvoiceDataBase.ReceiptOptions.Merge(store.GetStoreBlob().ReceiptOptions, invoice.ReceiptOptions) : null;
var redirectUrl = invoice?.RedirectURL?.ToString();
return receipt?.Enabled is true
? RedirectToAction(nameof(InvoiceReceipt), new { invoiceId })
: !string.IsNullOrEmpty(redirectUrl) ? Redirect(redirectUrl) : NotFound();
}
if (view == "modal")
model.IsModal = true;
@ -870,6 +874,13 @@ namespace BTCPayServer.Controllers
_paymentModelExtensions.TryGetValue(paymentMethodId, out var extension);
return extension?.Image ?? "";
}
// Show the "Common divisibility" rather than the payment method disibility.
// For example, BTC has commonly 8 digits, but on lightning it has 11. In this case, pick 8.
if (this._CurrencyNameTable.GetCurrencyData(prompt.Currency, false)?.Divisibility is not int divisibility)
divisibility = prompt.Divisibility;
string ShowMoney(decimal value) => MoneyExtensions.ShowMoney(value, divisibility);
var model = new PaymentModel
{
Activated = prompt.Activated,
@ -887,10 +898,11 @@ namespace BTCPayServer.Controllers
OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback,
CryptoImage = Request.GetRelativePathOrAbsolute(GetPaymentMethodImage(paymentMethodId)),
BtcAddress = prompt.Destination,
BtcDue = accounting.ShowMoney(accounting.Due),
BtcPaid = accounting.ShowMoney(accounting.Paid),
BtcDue = ShowMoney(accounting.Due),
BtcPaid = ShowMoney(accounting.Paid),
InvoiceCurrency = invoice.Currency,
OrderAmount = accounting.ShowMoney(accounting.TotalDue - accounting.PaymentMethodFee),
// The Tweak is part of the PaymentMethodFee, but let's not show it in the UI as it's negligible.
OrderAmount = ShowMoney(accounting.TotalDue - (prompt.PaymentMethodFee - prompt.TweakFee)),
IsUnsetTopUp = invoice.IsUnsetTopUp(),
CustomerEmail = invoice.Metadata.BuyerEmail,
ExpirationSeconds = Math.Max(0, (int)(invoice.ExpirationTime - DateTimeOffset.UtcNow).TotalSeconds),
@ -912,17 +924,9 @@ namespace BTCPayServer.Controllers
NetworkFeeMode.Never => 0,
_ => throw new NotImplementedException()
},
RequiredConfirmations = invoice.SpeedPolicy switch
{
SpeedPolicy.HighSpeed => 0,
SpeedPolicy.MediumSpeed => 1,
SpeedPolicy.LowMediumSpeed => 2,
SpeedPolicy.LowSpeed => 6,
_ => null
},
ReceivedConfirmations = handler is BitcoinLikePaymentHandler bh ? invoice.GetAllBitcoinPaymentData(bh, false).FirstOrDefault()?.ConfirmationCount : null,
Status = invoice.Status.ToString(),
NetworkFee = prompt.PaymentMethodFee,
// The Tweak is part of the PaymentMethodFee, but let's not show it in the UI as it's negligible.
NetworkFee = prompt.PaymentMethodFee - prompt.TweakFee,
IsMultiCurrency = invoice.GetPayments(false).Select(p => p.PaymentMethodId).Concat(new[] { prompt.PaymentMethodId }).Distinct().Count() > 1,
StoreId = store.Id,
AvailableCryptos = invoice.GetPaymentPrompts()

@ -513,8 +513,8 @@ namespace BTCPayServer.Controllers
{Policies.CanDeleteUser, ("Delete user", "Allows deleting the user to whom it is assigned. Admin users can delete any user without this permission.")},
{Policies.CanModifyStoreSettings, ("Modify your stores", "Allows managing invoices on all your stores and modify their settings.")},
{$"{Policies.CanModifyStoreSettings}:", ("Manage selected stores", "Allows managing invoices on the selected stores and modify their settings.")},
{Policies.CanModifyStoreWebhooks, ("Modify stores webhooks", "Allows modifying the webhooks of all your stores.")},
{$"{Policies.CanModifyStoreWebhooks}:", ("Modify selected stores' webhooks", "Allows modifying the webhooks of the selected stores.")},
{Policies.CanModifyWebhooks, ("Modify stores webhooks", "Allows modifying the webhooks of all your stores.")},
{$"{Policies.CanModifyWebhooks}:", ("Modify selected stores' webhooks", "Allows modifying the webhooks of the selected stores.")},
{Policies.CanViewStoreSettings, ("View your stores", "Allows viewing stores settings.")},
{$"{Policies.CanViewStoreSettings}:", ("View your stores", "Allows viewing the selected stores' settings.")},
{Policies.CanViewReports, ("View your reports", "Allows viewing reports.")},

@ -19,7 +19,7 @@ namespace BTCPayServer.Controllers
string sortOrder = null
)
{
var roles = await _StoreRepository.GetStoreRoles(null, true);
var roles = await _StoreRepository.GetStoreRoles(null);
var defaultRole = (await _StoreRepository.GetDefaultRole()).Role;
model ??= new RolesViewModel();
model.DefaultRole = defaultRole;

@ -88,11 +88,11 @@ namespace BTCPayServer.Controllers
{
prop.Value = "OK";
}
viewModel.Translations = Translations.CreateFromJson(jobj.ToString()).ToTextFormat();
viewModel.Translations = Translations.CreateFromJson(jobj.ToString()).ToJsonFormat();
}
if (!Translations.TryCreateFromText(viewModel.Translations, out var translations))
if (!Translations.TryCreateFromJson(viewModel.Translations, out var translations))
{
ModelState.AddModelError(nameof(viewModel.Translations), "Syntax error");
return View(viewModel);

@ -58,18 +58,28 @@ namespace BTCPayServer.Controllers
.Skip(model.Skip)
.Take(model.Count)
.ToListAsync())
.Select(u => new UsersViewModel.UserViewModel
.Select(u =>
{
Name = u.GetBlob()?.Name,
ImageUrl = u.GetBlob()?.ImageUrl,
Email = u.Email,
Id = u.Id,
EmailConfirmed = u.RequiresEmailConfirmation ? u.EmailConfirmed : null,
Approved = u.RequiresApproval ? u.Approved : null,
Created = u.Created,
Roles = u.UserRoles.Select(role => role.RoleId),
Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime,
Stores = u.UserStores.OrderBy(s => !s.StoreData.Archived).ToList()
var blob = u.GetBlob();
return new UsersViewModel.UserViewModel
{
Name = blob?.Name,
ImageUrl = blob?.ImageUrl,
Email = u.Email,
Id = u.Id,
InvitationUrl =
string.IsNullOrEmpty(blob?.InvitationToken)
? null
: _linkGenerator.InvitationLink(u.Id, blob.InvitationToken, Request.Scheme,
Request.Host, Request.PathBase),
EmailConfirmed = u.RequiresEmailConfirmation ? u.EmailConfirmed : null,
Approved = u.RequiresApproval ? u.Approved : null,
Created = u.Created,
Roles = u.UserRoles.Select(role => role.RoleId),
Disabled = u.LockoutEnabled && u.LockoutEnd != null &&
DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime,
Stores = u.UserStores.OrderBy(s => !s.StoreData.Archived).ToList()
};
})
.ToList();
return View(model);
@ -88,6 +98,7 @@ namespace BTCPayServer.Controllers
Id = user.Id,
Email = user.Email,
Name = blob?.Name,
InvitationUrl = string.IsNullOrEmpty(blob?.InvitationToken) ? null : _linkGenerator.InvitationLink(user.Id, blob.InvitationToken, Request.Scheme, Request.Host, Request.PathBase),
ImageUrl = string.IsNullOrEmpty(blob?.ImageUrl) ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob.ImageUrl)),
EmailConfirmed = user.RequiresEmailConfirmation ? user.EmailConfirmed : null,
Approved = user.RequiresApproval ? user.Approved : null,
@ -199,17 +210,47 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(User), new { userId });
}
[HttpGet("server/users/new")]
public IActionResult CreateUser()
[HttpGet("server/users/{userId}/reset-password")]
public async Task<IActionResult> ResetUserPassword(string userId)
{
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
return View();
var user = await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
return View(new ResetUserPasswordFromAdmin { Email = user.Email });
}
[HttpPost("server/users/{userId}/reset-password")]
public async Task<IActionResult> ResetUserPassword(string userId, ResetUserPasswordFromAdmin model)
{
var user = await _UserManager.FindByEmailAsync(model.Email);
if (user == null || user.Id != userId)
return NotFound();
var result = await _UserManager.ResetPasswordAsync(user, await _UserManager.GeneratePasswordResetTokenAsync(user), model.Password);
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = result.Succeeded ? StatusMessageModel.StatusSeverity.Success : StatusMessageModel.StatusSeverity.Error,
Message = result.Succeeded ? "Password successfully set" : "An error occurred while resetting user password"
});
return RedirectToAction(nameof(ListUsers));
}
[HttpGet("server/users/new")]
public async Task<IActionResult> CreateUser()
{
await PrepareCreateUserViewData();
var vm = new RegisterFromAdminViewModel
{
SendInvitationEmail = ViewData["CanSendEmail"] is true
};
return View(vm);
}
[HttpPost("server/users/new")]
public async Task<IActionResult> CreateUser(RegisterFromAdminViewModel model)
{
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
await PrepareCreateUserViewData();
if (!_Options.CheatMode)
model.IsAdmin = false;
if (ModelState.IsValid)
@ -236,6 +277,7 @@ namespace BTCPayServer.Controllers
var tcs = new TaskCompletionSource<Uri>();
var currentUser = await _UserManager.GetUserAsync(HttpContext.User);
var sendEmail = model.SendInvitationEmail && ViewData["CanSendEmail"] is true;
_eventAggregator.Publish(new UserRegisteredEvent
{
@ -243,23 +285,23 @@ namespace BTCPayServer.Controllers
Kind = UserRegisteredEventKind.Invite,
User = user,
InvitedByUser = currentUser,
SendInvitationEmail = sendEmail,
Admin = model.IsAdmin,
CallbackUrlGenerated = tcs
});
var callbackUrl = await tcs.Task;
var settings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
var info = settings.IsComplete()
? "An invitation email has been sent.<br/>You may alternatively"
: "An invitation email has not been sent, because the server does not have an email server configured.<br/> You need to";
var info = sendEmail
? "An invitation email has been sent. You may alternatively"
: "An invitation email has not been sent. You need to";
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
AllowDismiss = false,
Html = $"Account successfully created. {info} share this link with them: <a class='alert-link' href='{callbackUrl}'>{callbackUrl}</a>"
Html = $"Account successfully created. {info} share this link with them:<br/>{callbackUrl}"
});
return RedirectToAction(nameof(ListUsers));
return RedirectToAction(nameof(User), new { userId = user.Id });
}
foreach (var error in result.Errors)
@ -391,6 +433,31 @@ namespace BTCPayServer.Controllers
TempData[WellKnownTempData.SuccessMessage] = "Verification email sent";
return RedirectToAction(nameof(ListUsers));
}
private async Task PrepareCreateUserViewData()
{
var emailSettings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
ViewData["CanSendEmail"] = emailSettings.IsComplete();
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
}
}
public class ResetUserPasswordFromAdmin
{
[Required]
[EmailAddress]
[Display(Name = "Email")]
public string Email { get; set; }
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "Password")]
public string Password { get; set; }
[DataType(DataType.Password)]
[Display(Name = "Confirm password")]
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
public string ConfirmPassword { get; set; }
}
public class RegisterFromAdminViewModel
@ -415,5 +482,8 @@ namespace BTCPayServer.Controllers
[Display(Name = "Email confirmed?")]
public bool EmailConfirmed { get; set; }
[Display(Name = "Send invitation email")]
public bool SendInvitationEmail { get; set; } = true;
}
}

@ -150,7 +150,7 @@ namespace BTCPayServer.Controllers
Amount = model.Amount,
Currency = model.Currency,
StoreId = storeId,
PayoutMethodIds = selectedPaymentMethodIds,
PayoutMethods = selectedPaymentMethodIds,
BOLT11Expiration = TimeSpan.FromDays(model.BOLT11Expiration),
AutoApproveClaims = model.AutoApproveClaims
});
@ -586,7 +586,7 @@ namespace BTCPayServer.Controllers
private async Task<bool> HasPayoutProcessor(string storeId, PayoutMethodId payoutMethodId)
{
var processors = await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery { Stores = [storeId], PayoutMethodIds = [payoutMethodId] });
new PayoutProcessorService.PayoutProcessorQuery { Stores = [storeId], PayoutMethods = [payoutMethodId] });
return _payoutProcessorFactories.Any(factory => factory.GetSupportedPayoutMethods().Contains(payoutMethodId)) && processors.Any();
}
private async Task<bool> HasPayoutProcessor(string storeId, string payoutMethodId)

@ -538,12 +538,6 @@ public partial class UIStoresController
}
}
if (store.SpeedPolicy != vm.SpeedPolicy)
{
store.SpeedPolicy = vm.SpeedPolicy;
needUpdate = true;
}
if (needUpdate)
{
store.SetPaymentMethodConfig(handler, derivation);
@ -599,6 +593,12 @@ public partial class UIStoresController
needUpdate = true;
}
if (store.SpeedPolicy != vm.SpeedPolicy)
{
store.SpeedPolicy = vm.SpeedPolicy;
needUpdate = true;
}
if (needUpdate)
{
await _storeRepo.UpdateStore(store);

@ -21,7 +21,7 @@ public partial class UIStoresController
string sortOrder = null
)
{
var roles = await storeRepository.GetStoreRoles(storeId, true);
var roles = await storeRepository.GetStoreRoles(storeId);
var defaultRole = (await storeRepository.GetDefaultRole()).Role;
model ??= new RolesViewModel();
model.DefaultRole = defaultRole;

@ -34,6 +34,7 @@ public partial class UIStoresController
LogoUrl = await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), storeBlob.LogoUrl),
CssUrl = await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), storeBlob.CssUrl),
BrandColor = storeBlob.BrandColor,
ApplyBrandColorToBackend = storeBlob.ApplyBrandColorToBackend,
NetworkFeeMode = storeBlob.NetworkFeeMode,
AnyoneCanCreateInvoice = storeBlob.AnyoneCanInvoice,
PaymentTolerance = storeBlob.PaymentTolerance,
@ -75,10 +76,11 @@ public partial class UIStoresController
blob.RefundBOLT11Expiration = TimeSpan.FromDays(model.BOLT11Expiration);
if (!string.IsNullOrEmpty(model.BrandColor) && !ColorPalette.IsValid(model.BrandColor))
{
ModelState.AddModelError(nameof(model.BrandColor), "Invalid color");
ModelState.AddModelError(nameof(model.BrandColor), "The brand color needs to be a valid hex color code");
return View(model);
}
blob.BrandColor = model.BrandColor;
blob.ApplyBrandColorToBackend = model.ApplyBrandColorToBackend && !string.IsNullOrEmpty(model.BrandColor);
var userId = GetUserId();
if (userId is null)

@ -14,7 +14,6 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.ModelBinders;
using BTCPayServer.Models;
using BTCPayServer.Models.WalletViewModels;
@ -34,16 +33,13 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using NBitcoin;
using NBXplorer;
using NBXplorer.Client;
using NBXplorer.DerivationStrategy;
using NBXplorer.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers
@ -330,7 +326,7 @@ namespace BTCPayServer.Controllers
if (network == null)
return NotFound();
var store = GetCurrentStore();
var address = _walletReceiveService.Get(walletId)?.Address;
var address = (await _walletReceiveService.GetOrGenerate(walletId)).Address;
var allowedPayjoin = paymentMethod.IsHotWallet && store.GetStoreBlob().PayJoinEnabled;
var bip21 = network.GenerateBIP21(address?.ToString(), null);
if (allowedPayjoin)
@ -354,7 +350,7 @@ namespace BTCPayServer.Controllers
Address = address?.ToString(),
CryptoImage = GetImage(network),
PaymentLink = bip21.ToString(),
ReturnUrl = returnUrl ?? HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath,
ReturnUrl = returnUrl,
SelectedLabels = labels ?? Array.Empty<string>()
});
}
@ -373,18 +369,6 @@ namespace BTCPayServer.Controllers
return NotFound();
switch (command)
{
case "unreserve-current-address":
var address = await _walletReceiveService.UnReserveAddress(walletId);
if (!string.IsNullOrEmpty(address))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
AllowDismiss = true,
Message = $"Address {address} was unreserved.",
Severity = StatusMessageModel.StatusSeverity.Success,
});
}
break;
case "generate-new-address":
await _walletReceiveService.GetOrGenerate(walletId, true);
break;

@ -1,31 +0,0 @@
using System;
using BTCPayServer.Payments;
namespace BTCPayServer.Data
{
public static class AddressInvoiceDataExtensions
{
#pragma warning disable CS0618
public static string GetAddress(this AddressInvoiceData addressInvoiceData)
{
if (addressInvoiceData.Address == null)
return null;
var index = addressInvoiceData.Address.LastIndexOf("#", StringComparison.InvariantCulture);
if (index == -1)
return addressInvoiceData.Address;
return addressInvoiceData.Address.Substring(0, index);
}
public static PaymentMethodId GetPaymentMethodId(this AddressInvoiceData addressInvoiceData)
{
if (addressInvoiceData.Address == null)
return null;
var index = addressInvoiceData.Address.LastIndexOf("#", StringComparison.InvariantCulture);
// Legacy AddressInvoiceData does not have the paymentMethodId attached to the Address
if (index == -1)
return PaymentMethodId.Parse("BTC");
/////////////////////////
return PaymentMethodId.TryParse(addressInvoiceData.Address.Substring(index + 1));
}
#pragma warning restore CS0618
}
}

@ -32,7 +32,7 @@ namespace BTCPayServer.Data
#nullable enable
public static PayoutMethodId? GetClosestPayoutMethodId(this InvoiceData invoice, IEnumerable<PayoutMethodId> pmids)
{
var paymentMethodIds = invoice.Payments.Select(o => o.GetPaymentMethodId()).ToArray();
var paymentMethodIds = invoice.Payments.Select(o => PaymentMethodId.Parse(o.PaymentMethodId)).ToArray();
if (paymentMethodIds.Length == 0)
paymentMethodIds = invoice.GetBlob().GetPaymentPrompts().Select(p => p.PaymentMethodId).ToArray();
return PaymentMethodId.GetSimilarities(pmids, paymentMethodIds)
@ -73,7 +73,7 @@ namespace BTCPayServer.Data
entity.Status = state.Status;
if (invoiceData.AddressInvoices != null)
{
entity.AvailableAddressHashes = invoiceData.AddressInvoices.Select(a => a.GetAddress() + a.GetPaymentMethodId()).ToHashSet();
entity.Addresses = invoiceData.AddressInvoices.Select(a => (PaymentMethodId.Parse(a.PaymentMethodId), a.Address)).ToHashSet();
}
if (invoiceData.Refunds != null)
{

@ -34,20 +34,16 @@ namespace BTCPayServer.Data
}
public static PaymentData SetBlob(this PaymentData paymentData, PaymentMethodId paymentMethodId, PaymentBlob blob)
{
paymentData.Type = paymentMethodId.ToString();
paymentData.PaymentMethodId = paymentMethodId.ToString();
paymentData.Blob2 = JToken.FromObject(blob, InvoiceDataExtensions.DefaultSerializer).ToString(Newtonsoft.Json.Formatting.None);
return paymentData;
}
public static PaymentMethodId GetPaymentMethodId(this PaymentData paymentData)
{
return PaymentMethodId.Parse(paymentData.Type);
}
public static PaymentEntity GetBlob(this PaymentData paymentData)
{
var entity = JToken.Parse(paymentData.Blob2).ToObject<PaymentEntity>(InvoiceDataExtensions.DefaultSerializer) ?? throw new FormatException($"Invalid {nameof(PaymentEntity)}");
entity.Status = paymentData.Status!.Value;
entity.Currency = paymentData.Currency;
entity.PaymentMethodId = GetPaymentMethodId(paymentData);
entity.PaymentMethodId = PaymentMethodId.Parse(paymentData.PaymentMethodId);
entity.Value = paymentData.Amount!.Value;
entity.Id = paymentData.Id;
entity.ReceivedTime = paymentData.Created!.Value;

@ -319,23 +319,34 @@ namespace BTCPayServer.Data.Payouts.LightningLike
string message = null;
if (result.Result == PayResult.Ok)
{
message = result.Details?.TotalAmount != null
? $"Paid out {result.Details.TotalAmount.ToDecimal(LightMoneyUnit.BTC)}"
: null;
payoutData.State = PayoutState.Completed;
try
payoutData.State = result.Details?.Status switch
{
var payment = await lightningClient.GetPayment(bolt11PaymentRequest.PaymentHash.ToString(), cancellationToken);
proofBlob.Preimage = payment.Preimage;
}
catch (Exception)
LightningPaymentStatus.Pending => PayoutState.InProgress,
_ => PayoutState.Completed,
};
if (payoutData.State == PayoutState.Completed)
{
// ignored
message = result.Details?.TotalAmount != null
? $"Paid out {result.Details.TotalAmount.ToDecimal(LightMoneyUnit.BTC)}"
: null;
try
{
var payment = await lightningClient.GetPayment(bolt11PaymentRequest.PaymentHash.ToString(),
cancellationToken);
proofBlob.Preimage = payment.Preimage;
}
catch (Exception)
{
// ignored
}
}
}
else if (result.Result == PayResult.Unknown)
{
payoutData.State = PayoutState.InProgress;
}
if (payoutData.State == PayoutState.InProgress)
{
message = "The payment has been initiated but is still in-flight.";
}

@ -191,6 +191,8 @@ namespace BTCPayServer.Data
public List<UIStoresController.StoreEmailRule> EmailRules { get; set; }
public string BrandColor { get; set; }
public bool ApplyBrandColorToBackend { get; set; }
[JsonConverter(typeof(UnresolvedUriJsonConverter))]
public UnresolvedUri LogoUrl { get; set; }
[JsonConverter(typeof(UnresolvedUriJsonConverter))]

@ -1,15 +0,0 @@
namespace BTCPayServer.Events
{
public class InvoiceStopWatchedEvent : IHasInvoiceId
{
public InvoiceStopWatchedEvent(string invoiceId)
{
this.InvoiceId = invoiceId;
}
public string InvoiceId { get; set; }
public override string ToString()
{
return $"Invoice {InvoiceId} is not monitored anymore.";
}
}
}

@ -11,6 +11,7 @@ public class UserRegisteredEvent
public UserRegisteredEventKind Kind { get; set; } = UserRegisteredEventKind.Registration;
public Uri RequestUri { get; set; }
public ApplicationUser InvitedByUser { get; set; }
public bool SendInvitationEmail { get; set; }
public TaskCompletionSource<Uri> CallbackUrlGenerated;
}

@ -323,8 +323,8 @@ namespace BTCPayServer
public static IEnumerable<BitcoinLikePaymentData> GetAllBitcoinPaymentData(this InvoiceEntity invoice, BitcoinLikePaymentHandler handler, bool accountedOnly)
{
return invoice.GetPayments(accountedOnly)
.Where(p => p.PaymentMethodId == handler.PaymentMethodId)
.Select(p => handler.ParsePaymentDetails(p.Details));
.Select(p => p.GetDetails<BitcoinLikePaymentData>(handler))
.Where(p => p is not null);
}
public static async Task<Dictionary<uint256, TransactionResult>> GetTransactions(this BTCPayWallet client, uint256[] hashes, bool includeOffchain = false, CancellationToken cts = default(CancellationToken))

@ -11,6 +11,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Microsoft.Extensions.Hosting;
using MimeKit;
@ -45,6 +46,7 @@ namespace BTCPayServer.HostedServices
private readonly EmailSenderFactory _EmailSenderFactory;
private readonly StoreRepository _StoreRepository;
private readonly Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> _bitpayExtensions;
private readonly CurrencyNameTable _currencyNameTable;
public const string NamedClient = "bitpay-ipn";
public BitpayIPNSender(
IHttpClientFactory httpClientFactory,
@ -53,6 +55,7 @@ namespace BTCPayServer.HostedServices
InvoiceRepository invoiceRepository,
StoreRepository storeRepository,
Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> bitpayExtensions,
CurrencyNameTable currencyNameTable,
EmailSenderFactory emailSenderFactory)
{
_Client = httpClientFactory.CreateClient(NamedClient);
@ -62,11 +65,12 @@ namespace BTCPayServer.HostedServices
_EmailSenderFactory = emailSenderFactory;
_StoreRepository = storeRepository;
_bitpayExtensions = bitpayExtensions;
_currencyNameTable = currencyNameTable;
}
async Task Notify(InvoiceEntity invoice, InvoiceEvent invoiceEvent, bool extendedNotification, bool sendMail)
{
var dto = invoice.EntityToDTO(_bitpayExtensions);
var dto = invoice.EntityToDTO(_bitpayExtensions, _currencyNameTable);
var notification = new InvoicePaymentNotificationEventWrapper()
{
Data = new InvoicePaymentNotification()

@ -29,7 +29,7 @@ public abstract class BlobMigratorHostedService<TEntity> : IHostedService
public bool Complete { get; set; }
}
Task? _Migrating;
TaskCompletionSource _Cts = new TaskCompletionSource();
CancellationTokenSource? _Cts;
public BlobMigratorHostedService(
ILogger logs,
ISettingsRepository settingsRepository,
@ -46,7 +46,8 @@ public abstract class BlobMigratorHostedService<TEntity> : IHostedService
public Task StartAsync(CancellationToken cancellationToken)
{
_Migrating = Migrate(cancellationToken);
_Cts = new CancellationTokenSource();
_Migrating = Migrate(_Cts.Token);
return Task.CompletedTask;
}
public int BatchSize { get; set; } = 1000;
@ -61,40 +62,63 @@ public abstract class BlobMigratorHostedService<TEntity> : IHostedService
else
Logs.LogInformation("Migrating from the beginning");
int batchSize = BatchSize;
retry:
while (!cancellationToken.IsCancellationRequested)
{
retry:
List<TEntity> entities;
DateTimeOffset progress;
await using (var ctx = ApplicationDbContextFactory.CreateContext())
try
{
var query = GetQuery(ctx, settings?.Progress).Take(batchSize);
entities = await query.ToListAsync(cancellationToken);
if (entities.Count == 0)
List<TEntity> entities;
DateTimeOffset progress;
await using (var ctx = ApplicationDbContextFactory.CreateContext(o => o.CommandTimeout((int)TimeSpan.FromDays(1.0).TotalSeconds)))
{
await SettingsRepository.UpdateSetting<Settings>(new Settings() { Complete = true }, SettingsKey);
Logs.LogInformation("Migration completed");
return;
var query = GetQuery(ctx, settings?.Progress).Take(batchSize);
entities = await query.ToListAsync(cancellationToken);
if (entities.Count == 0)
{
var count = await GetQuery(ctx, null).CountAsync(cancellationToken);
if (count != 0)
{
settings = new Settings() { Progress = null };
Logs.LogWarning("Corruption detected, reindexing the table...");
await Reindex(ctx, cancellationToken);
goto retry;
}
await SettingsRepository.UpdateSetting<Settings>(new Settings() { Complete = true }, SettingsKey);
Logs.LogInformation("Migration completed");
await PostMigrationCleanup(ctx, cancellationToken);
return;
}
try
{
progress = ProcessEntities(ctx, entities);
await ctx.SaveChangesAsync();
batchSize = BatchSize;
}
catch (Exception ex) when (ex is DbUpdateConcurrencyException or TimeoutException or OperationCanceledException)
{
batchSize /= 2;
batchSize = Math.Max(1, batchSize);
goto retry;
}
}
try
{
progress = ProcessEntities(ctx, entities);
await ctx.SaveChangesAsync();
batchSize = BatchSize;
}
catch (DbUpdateConcurrencyException)
{
batchSize /= 2;
batchSize = Math.Max(1, batchSize);
goto retry;
}
settings = new Settings() { Progress = progress };
await SettingsRepository.UpdateSetting<Settings>(settings, SettingsKey);
}
catch (Exception ex)
{
Logs.LogError(ex, "Error while migrating");
goto retry;
}
settings = new Settings() { Progress = progress };
await SettingsRepository.UpdateSetting<Settings>(settings, SettingsKey);
}
}
protected abstract Task PostMigrationCleanup(ApplicationDbContext ctx, CancellationToken cancellationToken);
protected abstract Task Reindex(ApplicationDbContext ctx, CancellationToken cancellationToken);
protected abstract IQueryable<TEntity> GetQuery(ApplicationDbContext ctx, DateTimeOffset? progress);
protected abstract DateTimeOffset ProcessEntities(ApplicationDbContext ctx, List<TEntity> entities);
public async Task ResetMigration()
@ -108,11 +132,7 @@ retry:
public Task StopAsync(CancellationToken cancellationToken)
{
_Cts.TrySetCanceled();
return (_Migrating ?? Task.CompletedTask).ContinueWith(t =>
{
if (t.IsFaulted)
Logs.LogError(t.Exception, "Error while migrating");
});
_Cts?.Cancel();
return _Migrating ?? Task.CompletedTask;
}
}

@ -9,6 +9,7 @@ using AngleSharp.Dom;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Data;
using BTCPayServer.Services.Invoices;
using Dapper;
using Google.Apis.Logging;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
@ -22,7 +23,7 @@ namespace BTCPayServer.HostedServices;
public class InvoiceBlobMigratorHostedService : BlobMigratorHostedService<InvoiceData>
{
private readonly PaymentMethodHandlerDictionary _handlers;
public InvoiceBlobMigratorHostedService(
@ -42,9 +43,13 @@ public class InvoiceBlobMigratorHostedService : BlobMigratorHostedService<Invoic
ctx.Invoices.Include(o => o.Payments).Where(i => i.Currency == null);
return query.OrderByDescending(i => i.Created);
}
protected override Task Reindex(ApplicationDbContext ctx, CancellationToken cancellationToken)
{
return ctx.Database.ExecuteSqlRawAsync("REINDEX INDEX \"IX_Invoices_Created\";REINDEX INDEX \"PK_Invoices\";", cancellationToken);
}
protected override DateTimeOffset ProcessEntities(ApplicationDbContext ctx, List<InvoiceData> invoices)
{
// Those clean up the JSON blobs, and mark entities as modified
// Those clean up the JSON blobs
foreach (var inv in invoices)
{
var blob = inv.GetBlob();
@ -66,16 +71,21 @@ public class InvoiceBlobMigratorHostedService : BlobMigratorHostedService<Invoic
paymentEntity.Details = JToken.FromObject(handler.ParsePaymentDetails(paymentEntity.Details), handler.Serializer);
}
pay.SetBlob(paymentEntity);
if (pay.PaymentMethodId != pay.MigratedPaymentMethodId)
{
ctx.Entry(pay).State = EntityState.Added;
ctx.Payments.Remove(new PaymentData() { Id = pay.Id, PaymentMethodId = pay.MigratedPaymentMethodId });
}
}
}
foreach (var entry in ctx.ChangeTracker.Entries<InvoiceData>())
{
entry.State = EntityState.Modified;
}
foreach (var entry in ctx.ChangeTracker.Entries<PaymentData>())
{
entry.State = EntityState.Modified;
}
return invoices[^1].Created;
}
protected override async Task PostMigrationCleanup(ApplicationDbContext ctx, CancellationToken cancellationToken)
{
Logs.LogInformation("Post-migration VACUUM (ANALYZE)");
await ctx.Database.ExecuteSqlRawAsync("VACUUM (ANALYZE) \"Invoices\"", cancellationToken);
Logs.LogInformation("Post-migration VACUUM (ANALYZE) finished");
}
}

@ -24,7 +24,6 @@ namespace BTCPayServer.HostedServices
{
Subscribe<InvoiceEvent>();
Subscribe<InvoiceDataChangedEvent>();
Subscribe<InvoiceStopWatchedEvent>();
Subscribe<InvoiceIPNEvent>();
}

@ -14,6 +14,7 @@ using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NBitcoin;
@ -191,9 +192,9 @@ namespace BTCPayServer.HostedServices
}
}
private async Task Wait(string invoiceId)
private async Task Wait(string invoiceId) => await Wait(await _invoiceRepository.GetInvoice(invoiceId));
private async Task Wait(InvoiceEntity invoice)
{
var invoice = await _invoiceRepository.GetInvoice(invoiceId);
try
{
// add 1 second to ensure watch won't trigger moments before invoice expires
@ -202,7 +203,7 @@ namespace BTCPayServer.HostedServices
{
await Task.Delay(delay, _Cts.Token);
}
Watch(invoiceId);
Watch(invoice.Id);
// add 1 second to ensure watch won't trigger moments before monitoring expires
delay = invoice.MonitoringExpiration.AddSeconds(1) - DateTimeOffset.UtcNow;
@ -210,7 +211,7 @@ namespace BTCPayServer.HostedServices
{
await Task.Delay(delay, _Cts.Token);
}
Watch(invoiceId);
Watch(invoice.Id);
}
catch when (_Cts.IsCancellationRequested)
{ }
@ -255,8 +256,17 @@ namespace BTCPayServer.HostedServices
private async Task WaitPendingInvoices()
{
await Task.WhenAll((await _invoiceRepository.GetPendingInvoiceIds())
.Select(id => Wait(id)).ToArray());
await Task.WhenAll((await GetPendingInvoices(_Cts.Token))
.Select(i => Wait(i)).ToArray());
}
private async Task<InvoiceEntity[]> GetPendingInvoices(CancellationToken cancellationToken)
{
using var ctx = _invoiceRepository.DbContextFactory.CreateContext();
var rows = await ctx.Invoices.Where(i => Data.InvoiceData.IsPending(i.Status))
.Select(o => o).ToArrayAsync(cancellationToken);
var invoices = rows.Select(_invoiceRepository.ToEntity).ToArray();
return invoices;
}
async Task StartLoop(CancellationToken cancellation)
@ -292,24 +302,6 @@ namespace BTCPayServer.HostedServices
_eventAggregator.Publish(evt, evt.GetType());
}
if (invoice.Status == InvoiceStatus.Settled ||
((invoice.Status == InvoiceStatus.Invalid || invoice.Status == InvoiceStatus.Expired) && invoice.MonitoringExpiration < DateTimeOffset.UtcNow))
{
var extendInvoiceMonitoring = await UpdateConfirmationCount(invoice);
// we extend monitor time if we haven't reached max confirmation count
// say user used low fee and we only got 3 confirmations right before it's time to remove
if (extendInvoiceMonitoring)
{
await _invoiceRepository.ExtendInvoiceMonitor(invoice.Id);
}
else if (await _invoiceRepository.RemovePendingInvoice(invoice.Id))
{
_eventAggregator.Publish(new InvoiceStopWatchedEvent(invoice.Id));
}
break;
}
if (updateContext.Events.Count == 0)
break;
}
@ -324,48 +316,6 @@ namespace BTCPayServer.HostedServices
}
}
// TODO: Move that in the NBXplorerListener
private async Task<bool> UpdateConfirmationCount(InvoiceEntity invoice)
{
bool extendInvoiceMonitoring = false;
var updateConfirmationCountIfNeeded = invoice
.GetPayments(true)
.Select<PaymentEntity, Task<PaymentEntity>>(async payment =>
{
if (!_handlers.TryGetValue(payment.PaymentMethodId, out var h) || h is not Payments.Bitcoin.BitcoinLikePaymentHandler handler)
return null;
var onChainPaymentData = handler.ParsePaymentDetails(payment.Details);
var network = handler.Network;
// Do update if confirmation count in the paymentData is not up to date
if (onChainPaymentData.ConfirmationCount < network.MaxTrackedConfirmation)
{
var client = _explorerClientProvider.GetExplorerClient(payment.Currency);
var transactionResult = client is null ? null : await client.GetTransactionAsync(onChainPaymentData.Outpoint.Hash);
var confirmationCount = transactionResult?.Confirmations ?? 0;
onChainPaymentData.ConfirmationCount = confirmationCount;
payment.Status = NBXplorerListener.IsSettled(invoice, onChainPaymentData) ? PaymentStatus.Settled : PaymentStatus.Processing;
payment.SetDetails(handler, onChainPaymentData);
// we want to extend invoice monitoring until we reach max confirmations on all onchain payment methods
if (confirmationCount < network.MaxTrackedConfirmation)
extendInvoiceMonitoring = true;
return payment;
}
return null;
})
.ToArray();
await Task.WhenAll(updateConfirmationCountIfNeeded);
var updatedPaymentData = updateConfirmationCountIfNeeded.Where(a => a.Result != null).Select(a => a.Result).ToList();
if (updatedPaymentData.Count > 0)
{
await _paymentService.UpdatePayments(updatedPaymentData);
}
return extendInvoiceMonitoring;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_Cts == null)

@ -39,7 +39,7 @@ namespace BTCPayServer.HostedServices
public string Description { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
public PayoutMethodId[] PayoutMethodIds { get; set; }
public PayoutMethodId[] PayoutMethods { get; set; }
public bool AutoApproveClaims { get; set; }
public TimeSpan? BOLT11Expiration { get; set; }
}
@ -119,7 +119,7 @@ namespace BTCPayServer.HostedServices
Amount = request.Amount,
Currency = request.Currency,
StoreId = storeId,
PayoutMethodIds = request.PaymentMethods.Select(p => PayoutMethodId.Parse(p)).ToArray(),
PayoutMethods = request.PayoutMethods.Select(p => PayoutMethodId.Parse(p)).ToArray(),
AutoApproveClaims = request.AutoApproveClaims
});
}
@ -143,7 +143,7 @@ namespace BTCPayServer.HostedServices
{
Name = create.Name ?? string.Empty,
Description = create.Description ?? string.Empty,
SupportedPayoutMethods = create.PayoutMethodIds,
SupportedPayoutMethods = create.PayoutMethods,
AutoApproveClaims = create.AutoApproveClaims,
View = new PullPaymentBlob.PullPaymentView
{

@ -73,11 +73,12 @@ public class UserEventHostedService(
emailSender = await emailSenderFactory.GetEmailSender();
if (isInvite)
{
code = await userManager.GenerateInvitationTokenAsync(user);
code = await userManager.GenerateInvitationTokenAsync<ApplicationUser>(user.Id);
callbackUrl = generator.InvitationLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery);
ev.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
emailSender.SendInvitation(user.GetMailboxAddress(), callbackUrl);
if (ev.SendInvitationEmail)
emailSender.SendInvitation(user.GetMailboxAddress(), callbackUrl);
}
else if (requiresEmailConfirmation)
{

Some files were not shown because too many files have changed in this diff Show More