Compare commits

...

71 Commits

Author SHA1 Message Date
4d5245605d bump 2023-07-24 22:59:18 +09:00
453548d614 Checkout v2: Play sound when invoice is paid ()
* Checkout v2: Play sound when invoice is paid

Closes .

* Refactoring: Use low-level audio API to play the sound

Allows to play the sound regardless of browser permissions.

* Add audio file detection

* Use model state for file upload errors

* Add default sound and customizing option

* Fix mp3 detection

* Add sounds

* Update defaults

* Add nfcread and error sounds

* Improve label wording

* Replace sound

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2023-07-24 22:57:24 +09:00
95a0614ae1 Support accepting 0 amount bolt 11 invoices for payouts ()
* Support accepting 0 amount bolt 11 invoices for payouts

* add test

* handle validation better

* fix case when we just want pp to provide amt

* Update BTCPayServer/HostedServices/PullPaymentHostedService.cs

* Update BTCPayServer/HostedServices/PullPaymentHostedService.cs

* Update BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs

* Update UILightningLikePayoutController.cs

* fix null

* fix payments of payouts on cln

* add comment

* bump lightning lib

---------

Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
2023-07-24 20:40:26 +09:00
36ea17a6b7 Introduce Payout metadata for api and plugins ()
* Introduce Payout metadata for api and plugins

* fix controller

* fix metadata requirement

* save an object

* pr changes
2023-07-24 18:37:18 +09:00
dc986959fd Add reporting feature ()
* Add reporting feature

* Remove nodatime

* Add summaries

* work...

* Add chart title

* Fix error

* Allow to set hour in the field

* UI updates

* Fix fake data

* ViewDefinitions can be dynamic

* Add items sold

* Sticky table headers

* Update JS and remove jQuery usages

* JS click fix

* Handle tag all invoices for app

* fix dup row in items report

* Can cancel invoice request

* Add tests

* Fake data for items sold

* Rename Items to Products, improve navigation F5

* Use bordered table for summaries

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2023-07-24 09:24:32 +09:00
845e2881fa POS Cart redesign ()
* Move POS assets

* WIP

* Refactor into common Vue mixin

* Offcanvas updates

* Unifications across POS views

* POSData view fix

* Number and test fixes

* Update cart width

* Fix test

* More view unification

* Hide cart when emptied

* Validate cart

* Header improvement

* Increase remove icon size

* Animate add to cart action

* Offcanvas for mobile, sidebar for desktop

* ui+pos: updates icon size + badge + label

* Remove cart table headers

* Use same size for Cart and Shop headlines

* Update search placeholder

* Bump horizontal  input padding

* Increase sidebar width

* Bump badge font size

* Fix manipulating the quantity of line items

* Fix cart icon

* Update cart display

* updates empty button

* Rounded search input

* Remove cart button on desktop

* Fix dark accent color

* More accent fixes

* Fix plus/minus alignment

* Update BTCPayServer/Views/Shared/PointOfSale/Public/Cart.cshtml

* Apply suggestions from code review

---------

Co-authored-by: dstrukt <gfxdsign@gmail.com>
2023-07-22 21:15:41 +09:00
2e4be9310c Design system updates () 2023-07-21 09:27:37 +02:00
a2faa6fd59 Minor fixes () 2023-07-21 09:05:50 +02:00
0a78846e8d Stop using bitpay's CreateInvoice for non bitpay API usage () 2023-07-21 09:08:32 +09:00
4063a5aaee Quality of life improvements to payout processors ()
* Quality of life improvements to payout processors

* Allows more fleixble intervals for payout processing from 10-60 mins to 1min-24hours(requested by users)
* Cancel ln payotus that expired (bolt11)
* Allow cancelling of ln payotus that have failed to be paid after x attempts
* Allow conifguring a threshold for when to process on-chain payouts (reduces fees)

# Conflicts:
#	BTCPayServer.Tests/SeleniumTests.cs

* Simplify the code

* switch to concurrent dictionary

* Allow ProcessNewPayoutsInstantly

* refactor plugin hook service to have events available and change processor hooks to actions with better args

* add procesor extended tests

* Update BTCPayServer.Tests/GreenfieldAPITests.cs

* fix concurrency issue

* Update BTCPayServer/PayoutProcessors/BaseAutomatedPayoutProcessor.cs

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2023-07-20 22:05:14 +09:00
b1c81b696f Generate unique order IDs for PoS and Crowdfund sales ()
* Generate unique order IDs for PoS and Crowdfund sales

Part of .

* Refactorings

* Updates

* Updates

* Refactoring

* Remove search by AdditionalSearchTerm

* Implement appid

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2023-07-20 16:03:39 +09:00
0017f236a7 Improve create first store view ()
* Improve create first store view

Closes .

* Fix tests
2023-07-19 22:21:16 +09:00
19d5e64063 Form invoice amount adjusters ()
* Fix constant fields being editable on UI

* fix redirect to checkout if invoice is settled (redirect to receipt instead)

* enhance: make mirror field type able to map values

* Introduce invoice amount adjustment fields for form

* Integrate invoice amount adjustment fields for form on pos

* Support mirror in editor

* Indicate when special field names are used

* polsih mirror view and name suggestions for fields

* clarify

* hide hidden field from ui

* Minor adjustmentts

* Improve mirror field editing

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2023-07-19 18:54:51 +09:00
22435a2bf5 Refactor logic for calculating due amount of invoices ()
* Refactor logic for calculating due amount of invoices

* Remove Money type from the accounting

* Fix tests

* Fix a corner case

* fix bug

* Rename PaymentCurrency to Currency

* Fix bug

* Rename PaymentCurrency -> Currency

* Payment objects should have access to the InvoiceEntity

* Set Currency USD in tests

* Simplify some code

* Remove useless code

* Simplify code, kukks comment
2023-07-19 18:47:32 +09:00
a7def63137 fix pos item topups lnurl ()
fixes 
2023-07-17 13:08:41 +02:00
3703a170e7 try fix migration for pos yml 2023-07-13 14:59:18 +02:00
73fbfbd7cb Add support for Monero RPC authentication ()
Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>
2023-07-13 12:24:08 +02:00
acae3b8753 Refactoring 2023-07-13 12:17:41 +02:00
a618f901fc Support NFC on modal 2023-07-13 12:17:41 +02:00
6d4918f0ab Update ViewPullPayment.cshtml 2023-07-13 12:17:01 +02:00
7f2c4d2e7a add extension point for pull payment view 2023-07-13 12:17:01 +02:00
fd6d361e1a CheckoutV2: When WebSocket disconnects, we should continue polling via XHR ()
* When WebSocket disconnects, we should continue polling via XHR

* Update BTCPayServer/wwwroot/checkout-v2/checkout.js

Co-authored-by: d11n <mail@dennisreimann.de>

---------

Co-authored-by: d11n <mail@dennisreimann.de>
2023-07-11 21:56:13 +02:00
b5f0924651 Serialize PosAppCartItem.value as decimal instead of string 2023-07-11 15:49:16 +09:00
1600dd4759 POS: Backwards-compatible price parsing ()
* POS: Backwards-compatible price parsing

Fixes  and a regression introduced in bbff9710bf2f4a66bd6f4cd9e8ee55618d0ca5e0: The price in posData needs to be parsed in a backwards-compatible manner, as the old format of price as an object exists in the invoice metadata.

* Test corner cases

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2023-07-11 15:32:01 +09:00
c777746b69 Custom Forms: Allow HTML in labels and help text ()
* Custom Forms: Allow HTML in labels and help text

Fixes .

* Vue: Sanitize labels and helper text input

* Form editor: Fix blur on input for select option values

---------

Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
2023-07-11 13:02:02 +09:00
9f5466a41f Make sure CheckJsContent run as part of CI, and ignore end of line differences 2023-07-11 09:41:28 +09:00
4d1e4801bf Dark theme color fix 2023-07-10 11:33:39 +02:00
5e469ff9c0 Improve rates ()
* Removes Chaincoin shitcoin which is so dead even its website is gone
* Add ExchangeRateHost and FreeCurrencyRates as new rate providers
* Add recommended rate providers for UGX and RSD
* Fix BTX rate by switching to graviex
* Fix BTC rate by switching to exmo
* Fix LCAD rate script
2023-07-10 17:31:48 +09:00
2f3eedea5b Invoice lists: Show icons for payment methods () 2023-07-08 17:33:13 +02:00
5c5d6dc1e2 Bumping LND to 0.16.4-beta 2023-07-08 08:22:42 -05:00
fbe31ce64f Support LNURL in pay button ()
* Support LNURL in pay button

* UI updates

* Cleanups

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2023-07-06 10:12:31 +02:00
0b082138c8 Payment Requests: List view improvements ()
* List invoice checkbox variant

* Remove custom css

* Improve payment requests list view

* Improve Payment Requests List View

* List invoice checkbox variant

* Remove custom css

* Improve payment requests list view

* Improve Payment Requests List View

* Update payment request (name link leads to view not edit)

* Refactoring

---------

Co-authored-by: d11n <mail@dennisreimann.de>
2023-07-06 10:02:23 +02:00
966e598f10 Apps: Add direct file upload in item editor () 2023-07-06 11:01:36 +09:00
e998340387 POS: Account for custom amount in cart view ()
* Add failing test

* Account for custom amount

* Test fix
2023-07-05 17:23:15 +09:00
f6b27cc5f9 Compare domains in lowercase
Domains are case-insensitive, so this comparision should be too.

I encountered this issue with a Citadel user who accidentially named their domain an uppercase name (Pay.example.com), but browsers automatically converted it to pay.example.com
2023-07-03 08:49:16 +02:00
f3dbf1e139 Allow browser to access LND config () 2023-06-30 15:08:23 +09:00
627d84fc91 Update to Bootstrap v5.3 ()
Based on 
2023-06-30 09:21:27 +09:00
8cde8c01df Add category feature to the PoS with Cart ()
* Add grouping feature to the PoS with Cart

* Improve UI

* Rename groups to categories

* Make it easier to select categories of the items

* Refactor TemplateEditor, use TomSelect for categories

* Prevent Vue code insertion

* Prevent empty categories

* Add label ids

* Add test case

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2023-06-30 09:13:15 +09:00
983b8c1f54 Fix changelog 2023-06-27 21:32:05 +09:00
d666d8ea1a Changelog 1.10.3 ()
* Changelog 1.10.3

* Apply suggestions from code review

Co-authored-by: d11n <mail@dennisreimann.de>

* Apply suggestions from code review

* Update changelog

---------

Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>
Co-authored-by: d11n <mail@dennisreimann.de>
2023-06-27 21:23:56 +09:00
3ed81c3a78 Greenfield: Fix missing default currency in stores API
Docs mention it should be present, but it wasn't. Fixes .
2023-06-27 12:52:24 +02:00
4afec2e2b6 Fix: Using lnaddresses on Nostr should not result in lots of invoice being created 2023-06-27 12:50:24 +02:00
db83d238d5 Crowdfund: Fix JS errors in empty state ()
An empty crfowdfund with the default perk had JS errors.
2023-06-27 09:42:18 +09:00
fdcf7b3b7a Bumping LND to 0.16.3-beta () 2023-06-27 09:06:31 +09:00
53aafcf86b Fix: The current preimage of a invoice's lightning payment method should be available via API () 2023-06-23 19:12:11 +09:00
aec84f6d67 Dashboard: Limit "Top Items" to five ()
Feedback we got at BTCPrague: Do not show more than five items in the top list, because otherwise the list can get very long if there's a POS with many items.
2023-06-23 11:31:05 +02:00
01e9f82d24 Policies: Update wording to fit API keys and Roles ()
* Policies: Update wording to fit API keys and Roles

Closes .

* API keys: Improve spacing
2023-06-22 10:37:30 +02:00
2eff45e65c Ajaxify the wallet transaction list to avoid timeout (Fix ) ()
* Ajaxify the wallet transaction list to avoid timeout (Fix )

* Add cancellation to request to wallet transactions

* Fix tests

* Improve empty state

* Cleanups

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2023-06-22 16:09:53 +09:00
13203c3e2b Receipt improvements ()
* Remove Order ID link

* Add separate print version for receipt

* Fix POS number handling and add keypad test

Fixes .

* Add formatting function

* Remove OrderUrl for POS, bring back order link for receipt

* Update BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
2023-06-22 15:57:29 +09:00
82c5e0e43d Dashboard: Make invoice badges consistent with those on invoices list ()
Closes .
2023-06-22 15:47:12 +09:00
a1575f404b Invoices: Fix search box shrinking too small ()
Fixes .
2023-06-21 09:52:42 +02:00
e1509506dc Upgrade Bootstrap-Vue and fix tooltip positioning
Fixes .
2023-06-21 08:31:13 +02:00
0c1d0d7b05 Fix: formResponse and formId missing from API's GetPaymentRequest route 2023-06-21 12:47:21 +09:00
ad70856af0 Fix: LN payments failed to be detected on litd () 2023-06-21 12:15:46 +09:00
8615f120ce Fix tests 2023-06-20 22:37:05 +09:00
0d0477d661 Lightning: Relax GetInfo constraint for LNDhub connections ()
* Lightning: Relax GetInfo constraint for LNDhub connections

The LNDhub-compatible implementation by LNbits does not support the `GetInfo` call for all their funding sources — see lnbits/lnbits#1182. By catching that exception in combination with the `LndHubLightningClient`, we give people the ability to still use their LNbits-based LNDhub as a Lightning node.

Fixes .

* Update approach to handling unsupported GetInfo calls
2023-06-20 17:28:16 +09:00
b31dc30878 Make file management UI more useful ()
* Make file management UI more useful

* Simplify markup

* Move file info to top

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2023-06-20 08:58:28 +02:00
6e392f4cfb After changing PoS items in UpdatePoS ident the JSON template 2023-06-19 14:44:12 +09:00
cc3bdc331e Fix build 2023-06-16 23:19:47 +09:00
76faf77a1c Fix keypad view broken by previous commit 2023-06-16 23:18:47 +09:00
d8c0e5bf3a Add extension point to template editor () 2023-06-16 23:05:49 +09:00
28c4c320cc Checkout v2: Add return link in processing state ()
* Checkout v2: Add return link in processing state

* Update copy text position
2023-06-16 23:05:08 +09:00
e81403ec3f Fix: Applying a discount in PoS with cart wasn't working () 2023-06-16 23:02:14 +09:00
f11424f73a Pull Payment: Support LNURL Withdraw with SATS denomination ()
* Pull Payment: Support LNURL Withdraw with SATS denomination

* Refactor and add tests
2023-06-16 10:56:17 +09:00
fa8b977016 Remove id from create webhook endpoint; fix consistency. () 2023-06-16 10:53:41 +09:00
d181846339 Refund: Fix overpaid option ()
Closes .
2023-06-16 10:52:52 +09:00
1956919886 Do not crash when an invoice have an amount that is too big () 2023-06-16 10:47:58 +09:00
0f66498965 NFC: Do not start scanning if unsupported
Fixes .
2023-06-14 09:14:09 +02:00
918cd152b1 Fix: Incorrect rounding in the receipt of PoS invoice (fix ) () 2023-06-13 20:34:21 +02:00
d3222df396 Fix build warnings ()
Fixes these two:

```
/source/BTCPayServer/Hosting/MigrationStartupTask.cs(643,49): warning CS0168: The variable 'items' is declared but never used [/source/BTCPayServer/BTCPayServer.csproj]
/source/BTCPayServer/Hosting/MigrationStartupTask.cs(644,24): warning CS0168: The variable 'newTemplate' is declared but never used [/source/BTCPayServer/BTCPayServer.csproj]
```
2023-06-13 20:46:44 +09:00
a84ffd8c7e Crowdfund: Fix null pointer exception for topup type (missing price) ()
Items with type topup have a price = null and hence not even the property set (ignored in JSON). This needs to be handled in the temlate, otherwise this exception occurs:

```
An unhandled exception was thrown by the application.
System.InvalidOperationException: Nullable object must have a value.
   at AspNetCoreGeneratedDocument.Views_Shared_Crowdfund_Public_ContributeForm.<>c__DisplayClass24_0.<<ExecuteAsync>b__0>d.MoveNext()
```
2023-06-13 20:46:27 +09:00
239 changed files with 8404 additions and 21189 deletions
BTCPayServer.Abstractions
BTCPayServer.Client/Models
BTCPayServer.Common
BTCPayServer.Data
BTCPayServer.Rating
BTCPayServer.Tests
BTCPayServer
BTCPayServer.csproj
Components
Controllers
Data
Extensions.cs
Extensions
FileTypeDetector.cs
Forms
HostedServices
Hosting
Models
PaymentRequest
Payments
PayoutProcessors
Plugins
Program.csSearchString.cs
Security/GreenField
Services
TagHelpers
Views
wwwroot
Build
Changelog.mdbtcpayserver.sln.DotSettings

@ -1,3 +1,4 @@
using System;
using System.Threading.Tasks;
namespace BTCPayServer.Abstractions.Contracts
@ -6,5 +7,8 @@ namespace BTCPayServer.Abstractions.Contracts
{
Task ApplyAction(string hook, object args);
Task<object> ApplyFilter(string hook, object args);
event EventHandler<(string hook, object args)> ActionInvoked;
event EventHandler<(string hook, object args)> FilterInvoked;
}
}

@ -12,13 +12,11 @@ public class PermissionTagHelper : TagHelper
{
private readonly IAuthorizationService _authorizationService;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<PermissionTagHelper> _logger;
public PermissionTagHelper(IAuthorizationService authorizationService, IHttpContextAccessor httpContextAccessor, ILogger<PermissionTagHelper> logger)
public PermissionTagHelper(IAuthorizationService authorizationService, IHttpContextAccessor httpContextAccessor)
{
_authorizationService = authorizationService;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public string Permission { get; set; }

@ -1,8 +1,11 @@
#nullable enable
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models;
public class CreatePayoutThroughStoreRequest : CreatePayoutRequest
{
public string? PullPaymentId { get; set; }
public bool? Approved { get; set; }
public JObject? Metadata { get; set; }
}

@ -10,4 +10,9 @@ public class LightningAutomatedPayoutSettings
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
public TimeSpan IntervalSeconds { get; set; }
public int? CancelPayoutAfterFailures { get; set; }
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public bool ProcessNewPayoutsInstantly { get; set; }
}

@ -12,4 +12,8 @@ public class OnChainAutomatedPayoutSettings
public TimeSpan IntervalSeconds { get; set; }
public int? FeeBlockTarget { get; set; }
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public decimal Threshold { get; set; }
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public bool ProcessNewPayoutsInstantly { get; set; }
}

@ -31,5 +31,6 @@ namespace BTCPayServer.Client.Models
public PayoutState State { get; set; }
public int Revision { get; set; }
public JObject PaymentProof { get; set; }
public JObject Metadata { get; set; }
}
}

@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models;
public class StoreReportRequest
{
public string ViewName { get; set; }
public TimePeriod TimePeriod { get; set; }
}
public class StoreReportResponse
{
public class Field
{
public Field()
{
}
public Field(string name, string type)
{
Name = name;
Type = type;
}
public string Name { get; set; }
public string Type { get; set; }
}
public IList<Field> Fields { get; set; } = new List<Field>();
public List<JArray> Data { get; set; }
public DateTimeOffset From { get; set; }
public DateTimeOffset To { get; set; }
public List<ChartDefinition> Charts { get; set; }
public int GetIndex(string fieldName)
{
return Fields.ToList().FindIndex(f => f.Name == fieldName);
}
}
public class ChartDefinition
{
public string Name { get; set; }
public List<string> Groups { get; set; } = new List<string>();
public List<string> Totals { get; set; } = new List<string>();
public bool HasGrandTotal { get; set; }
public List<string> Aggregates { get; set; } = new List<string>();
public List<string> Filters { get; set; } = new List<string>();
}
public class TimePeriod
{
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? From { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? To { get; set; }
}

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace BTCPayServer.Client.Models
{
public class StoreReportsResponse
{
public string ViewName { get; set; }
public StoreReportResponse.Field[] Fields
{
get;
set;
}
}
}

@ -16,7 +16,7 @@ namespace BTCPayServer
DefaultRateRules = new[]
{
"BTG_X = BTG_BTC * BTC_X",
"BTG_BTC = bitfinex(BTG_BTC)",
"BTG_BTC = exmo(BTG_BTC)",
},
CryptoImagePath = "imlegacy/btg.svg",
LightningImagePath = "imlegacy/btg-lightning.svg",

@ -17,7 +17,7 @@ namespace BTCPayServer
DefaultRateRules = new[]
{
"BTX_X = BTX_BTC * BTC_X",
"BTX_BTC = hitbtc(BTX_BTC)"
"BTX_BTC = graviex(BTX_BTC)"
},
CryptoImagePath = "imlegacy/bitcore.svg",
LightningImagePath = "imlegacy/bitcore-lightning.svg",

@ -1,32 +0,0 @@
using NBitcoin;
namespace BTCPayServer
{
public partial class BTCPayNetworkProvider
{
public void InitChaincoin()
{
var nbxplorerNetwork = NBXplorerNetworkProvider.GetFromCryptoCode("CHC");
Add(new BTCPayNetwork()
{
CryptoCode = nbxplorerNetwork.CryptoCode,
DisplayName = "Chaincoin",
BlockExplorerLink = NetworkType == ChainName.Mainnet
? "https://explorer.chaincoin.org/Explorer/Transaction/{0}"
: "https://test.explorer.chaincoin.org/Explorer/Transaction/tx/{0}",
NBXplorerNetwork = nbxplorerNetwork,
DefaultRateRules = new[]
{
"CHC_X = CHC_BTC * BTC_X",
"CHC_BTC = txbit(CHC_X)"
},
CryptoImagePath = "imlegacy/chaincoin.png",
DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType),
//https://github.com/satoshilabs/slips/blob/master/slip-0044.md
CoinType = NetworkType == ChainName.Mainnet ? new KeyPath("711'")
: new KeyPath("1'")
});
}
}
}

@ -63,6 +63,7 @@ namespace BTCPayServer
"LCAD_CAD = 1",
"LCAD_X = CAD_BTC * BTC_X",
"LCAD_BTC = bylls(CAD_BTC)",
"CAD_BTC = LCAD_BTC"
},
AssetId = new uint256("0e99c1a6da379d1f4151fb9df90449d40d0608f6cb33a5bcbfc8c265f42bab0a"),
DisplayName = "Liquid CAD",

@ -1,4 +1,5 @@
#if ALTCOINS
using System;
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Common;
@ -34,12 +35,12 @@ namespace BTCPayServer
output.Value is AssetMoney assetMoney && assetMoney.AssetId == AssetId));
}
public override PaymentUrlBuilder GenerateBIP21(string cryptoInfoAddress, Money cryptoInfoDue)
public override PaymentUrlBuilder GenerateBIP21(string cryptoInfoAddress, decimal? cryptoInfoDue)
{
//precision 0: 10 = 0.00000010
//precision 2: 10 = 0.00001000
//precision 8: 10 = 10
var money = cryptoInfoDue is null ? null : new Money(cryptoInfoDue.ToDecimal(MoneyUnit.BTC) / decimal.Parse("1".PadRight(1 + 8 - Divisibility, '0')), MoneyUnit.BTC);
var money = cryptoInfoDue / (decimal)Math.Pow(10, 8 - Divisibility);
var builder = base.GenerateBIP21(cryptoInfoAddress, money);
builder.QueryParams.Add("assetid", AssetId.ToString());
return builder;

@ -45,10 +45,10 @@ namespace BTCPayServer.Services.Altcoins.Monero.RPC
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic",
Convert.ToBase64String(Encoding.Default.GetBytes($"{_username}:{_password}")));
var rawResult = await _httpClient.SendAsync(httpRequest, cts);
var rawJson = await rawResult.Content.ReadAsStringAsync();
HttpResponseMessage rawResult = await _httpClient.SendAsync(httpRequest, cts);
rawResult.EnsureSuccessStatusCode();
var rawJson = await rawResult.Content.ReadAsStringAsync();
JsonRpcResult<TResponse> response;
try
{

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using BTCPayServer.Common;
@ -87,13 +88,13 @@ namespace BTCPayServer
});
}
public virtual PaymentUrlBuilder GenerateBIP21(string cryptoInfoAddress, Money cryptoInfoDue)
public virtual PaymentUrlBuilder GenerateBIP21(string cryptoInfoAddress, decimal? cryptoInfoDue)
{
var builder = new PaymentUrlBuilder(this.NBitcoinNetwork.UriScheme);
builder.Host = cryptoInfoAddress;
if (cryptoInfoDue != null && cryptoInfoDue != Money.Zero)
if (cryptoInfoDue is not null && cryptoInfoDue.Value != 0.0m)
{
builder.QueryParams.Add("amount", cryptoInfoDue.ToString(false, true));
builder.QueryParams.Add("amount", cryptoInfoDue.Value.ToString(CultureInfo.InvariantCulture));
}
return builder;
}

@ -56,7 +56,6 @@ namespace BTCPayServer
InitViacoin();
InitMonero();
InitZcash();
InitChaincoin();
// InitArgoneum();//their rate source is down 9/15/20.
// InitMonetaryUnit(); Not supported from Bittrex from 11/23/2022, dead shitcoin

@ -1,3 +1,6 @@
using System;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Models;
using Microsoft.EntityFrameworkCore;

@ -8,6 +8,7 @@ namespace BTCPayServer.Data;
public class AutomatedPayoutBlob
{
public TimeSpan Interval { get; set; } = TimeSpan.FromHours(1);
public bool ProcessNewPayoutsInstantly { get; set; }
}
public class PayoutProcessorData : IHasBlobUntyped
{

@ -15,7 +15,7 @@ namespace BTCPayServer.Rating
while (true)
{
var rounded = decimal.Round(value, divisibility, MidpointRounding.AwayFromZero);
if ((Math.Abs(rounded - value) / value) < 0.001m)
if ((Math.Abs(rounded - value) / value) < 0.01m)
{
value = rounded;
break;

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Rating;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services.Rates;
public class ExchangeRateHostRateProvider : IRateProvider
{
public RateSourceInfo RateSourceInfo => new("exchangeratehost", "Yadio", "https://api.exchangerate.host/latest?base=BTC");
private readonly HttpClient _httpClient;
public ExchangeRateHostRateProvider(HttpClient httpClient)
{
_httpClient = httpClient ?? new HttpClient();
}
public async Task<PairRate[]> GetRatesAsync(CancellationToken cancellationToken)
{
var response = await _httpClient.GetAsync(RateSourceInfo.Url, cancellationToken);
response.EnsureSuccessStatusCode();
var jobj = await response.Content.ReadAsAsync<JObject>(cancellationToken);
if(jobj["success"].Value<bool>() is not true || !jobj["base"].Value<string>().Equals("BTC", StringComparison.InvariantCulture))
throw new Exception("exchangerate.host returned a non success response or the base currency was not the requested one (BTC)");
var results = (JObject) jobj["rates"] ;
//key value is currency code to rate value
var list = new List<PairRate>();
foreach (var item in results)
{
string name = item.Key;
var value = item.Value.Value<decimal>();
list.Add(new PairRate(new CurrencyPair("BTC", name), new BidAsk(value)));
}
return list.ToArray();
}
}

@ -0,0 +1,36 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Rating;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services.Rates;
public class FreeCurrencyRatesRateProvider : IRateProvider
{
public RateSourceInfo RateSourceInfo => new("free-currency-rates", "Free Currency Rates", "https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@1/latest/currencies/btc.min.json");
private readonly HttpClient _httpClient;
public FreeCurrencyRatesRateProvider(HttpClient httpClient)
{
_httpClient = httpClient ?? new HttpClient();
}
public async Task<PairRate[]> GetRatesAsync(CancellationToken cancellationToken)
{
var response = await _httpClient.GetAsync(RateSourceInfo.Url, cancellationToken);
response.EnsureSuccessStatusCode();
var jobj = await response.Content.ReadAsAsync<JObject>(cancellationToken);
var results = (JObject) jobj["btc"] ;
//key value is currency code to rate value
var list = new List<PairRate>();
foreach (var item in results)
{
string name = item.Key;
var value = item.Value.Value<decimal>();
list.Add(new PairRate(new CurrencyPair("BTC", name), new BidAsk(value)));
}
return list.ToArray();
}
}

@ -669,7 +669,7 @@ donation:
Assert.Equal("Wanna tip?", vmview.CustomTipText);
Assert.Equal("15,18,20", string.Join(',', vmview.CustomTipPercentages));
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 0, null, null, null, null, "orange").Result);
.ViewPointOfSale(app.Id, PosViewType.Cart, 0, choiceKey: "orange").Result);
//
var invoices = await user.BitPay.GetInvoicesAsync();
@ -678,7 +678,7 @@ donation:
Assert.Equal("CAD", orangeInvoice.Currency);
Assert.Equal("orange", orangeInvoice.ItemDesc);
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 0, null, null, null, null, "apple").Result);
.ViewPointOfSale(app.Id, PosViewType.Cart, 0, choiceKey: "apple").Result);
invoices = user.BitPay.GetInvoices();
var appleInvoice = invoices.SingleOrDefault(invoice => invoice.ItemCode.Equals("apple"));
@ -687,7 +687,7 @@ donation:
// testing custom amount
var action = Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 6.6m, null, null, null, null, "donation").Result);
.ViewPointOfSale(app.Id, PosViewType.Cart, 6.6m, choiceKey: "donation").Result);
Assert.Equal(nameof(UIInvoiceController.Checkout), action.ActionName);
invoices = user.BitPay.GetInvoices();
var donationInvoice = invoices.Single(i => i.Price == 6.6m);
@ -760,20 +760,20 @@ noninventoryitem:
await tester.WaitForEvent<AppInventoryUpdaterHostedService.UpdateAppInventory>(() =>
{
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, null, null, null, null, "inventoryitem").Result);
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, choiceKey: "inventoryitem").Result);
return Task.CompletedTask;
});
//we already bought all available stock so this should fail
await Task.Delay(100);
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, null, null, null, null, "inventoryitem").Result);
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, choiceKey: "inventoryitem").Result);
//inventoryitem has unlimited items available
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, null, null, null, null, "noninventoryitem").Result);
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, choiceKey: "noninventoryitem").Result);
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, null, null, null, null, "noninventoryitem").Result);
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, choiceKey: "noninventoryitem").Result);
//verify invoices where created
invoices = user.BitPay.GetInvoices();
@ -809,9 +809,9 @@ normal:
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, null, null, null, null, "btconly").Result);
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, choiceKey: "btconly").Result);
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, null, null, null, null, "normal").Result);
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, choiceKey: "normal").Result);
invoices = user.BitPay.GetInvoices();
var normalInvoice = invoices.Single(invoice => invoice.ItemCode == "normal");
var btcOnlyInvoice = invoices.Single(invoice => invoice.ItemCode == "btconly");
@ -865,7 +865,7 @@ g:
Assert.Contains(items, item => item.Id == "g" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup);
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Static, null, null, null, null, null, "g").Result);
.ViewPointOfSale(app.Id, PosViewType.Static, choiceKey: "g").Result);
invoices = user.BitPay.GetInvoices();
var topupInvoice = invoices.Single(invoice => invoice.ItemCode == "g");
Assert.Equal(0, topupInvoice.Price);

@ -23,7 +23,7 @@
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.14" />
<PackageReference Include="Selenium.Support" Version="4.1.1" />
<PackageReference Include="Selenium.WebDriver" Version="4.1.1" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="112.0.5615.4900" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="114.0.5735.9000" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<PrivateAssets>all</PrivateAssets>

@ -1,4 +1,5 @@
using System;
using System.Diagnostics;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@ -122,6 +123,13 @@ retry:
driver.ExecuteJavaScript($"document.getElementById('{element}').{funcName}()");
}
public static void WaitWalletTransactionsLoaded(this IWebDriver driver)
{
var wait = new WebDriverWait(driver, SeleniumTester.ImplicitWait);
wait.UntilJsIsReady();
wait.Until(d => d.WaitForElement(By.CssSelector("#WalletTransactions[data-loaded='true']")));
}
public static IWebElement WaitForElement(this IWebDriver driver, By selector)
{
var wait = new WebDriverWait(driver, SeleniumTester.ImplicitWait);
@ -189,6 +197,7 @@ retry:
driver.FindElement(selector).Click();
}
[DebuggerHidden]
public static bool ElementDoesNotExist(this IWebDriver driver, By selector)
{
Assert.Throws<NoSuchElementException>(() =>

@ -346,165 +346,213 @@ namespace BTCPayServer.Tests
Assert.True(Torrc.TryParse(input, out torrc));
Assert.Equal(expected, torrc.ToString());
}
[Fact]
public void CanCalculateDust()
{
var entity = new InvoiceEntity() { Currency = "USD" };
entity.Networks = new BTCPayNetworkProvider(ChainName.Regtest);
#pragma warning disable CS0618
entity.Payments = new System.Collections.Generic.List<PaymentEntity>();
entity.SetPaymentMethod(new PaymentMethod()
{
Currency = "BTC",
Rate = 34_000m
});
entity.Price = 4000;
entity.UpdateTotals();
var accounting = entity.GetPaymentMethods().First().Calculate();
// Exact price should be 0.117647059..., but the payment method round up to one sat
Assert.Equal(0.11764706m, accounting.Due);
entity.Payments.Add(new PaymentEntity()
{
Currency = "BTC",
Output = new TxOut(Money.Coins(0.11764706m), new Key()),
Accounted = true
});
entity.UpdateTotals();
Assert.Equal(0.0m, entity.NetDue);
// The dust's value is below 1 sat
Assert.True(entity.Dust > 0.0m);
Assert.True(Money.Satoshis(1.0m).ToDecimal(MoneyUnit.BTC) * entity.Rates["BTC"] > entity.Dust);
Assert.True(!entity.IsOverPaid);
Assert.True(!entity.IsUnderPaid);
// Now, imagine there is litecoin. It might seem from its
// perspecitve that there has been a slight over payment.
// However, Calculate() should just cap it to 0.0m
entity.SetPaymentMethod(new PaymentMethod()
{
Currency = "LTC",
Rate = 3400m
});
entity.UpdateTotals();
var method = entity.GetPaymentMethods().First(p => p.Currency == "LTC");
accounting = method.Calculate();
Assert.Equal(0.0m, accounting.DueUncapped);
#pragma warning restore CS0618
}
#if ALTCOINS
[Fact]
public void CanCalculateCryptoDue()
{
var networkProvider = new BTCPayNetworkProvider(ChainName.Regtest);
var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[]
{
new BitcoinLikePaymentHandler(null, networkProvider, null, null, null, null),
new LightningLikePaymentHandler(null, null, networkProvider, null, null, null),
});
var entity = new InvoiceEntity();
var entity = new InvoiceEntity() { Currency = "USD" };
entity.Networks = networkProvider;
#pragma warning disable CS0618
entity.Payments = new System.Collections.Generic.List<PaymentEntity>();
entity.SetPaymentMethod(new PaymentMethod()
{
CryptoCode = "BTC",
Currency = "BTC",
Rate = 5000,
NextNetworkFee = Money.Coins(0.1m)
});
entity.Price = 5000;
entity.UpdateTotals();
var paymentMethod = entity.GetPaymentMethods().TryGet("BTC", PaymentTypes.BTCLike);
var accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(1.1m), accounting.Due);
Assert.Equal(Money.Coins(1.1m), accounting.TotalDue);
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);
entity.Payments.Add(new PaymentEntity()
{
Currency = "BTC",
Output = new TxOut(Money.Coins(0.5m), new Key()),
Rate = 5000,
Accounted = true,
NetworkFee = 0.1m
});
entity.UpdateTotals();
accounting = paymentMethod.Calculate();
//Since we need to spend one more txout, it should be 1.1 - 0,5 + 0.1
Assert.Equal(Money.Coins(0.7m), accounting.Due);
Assert.Equal(Money.Coins(1.2m), accounting.TotalDue);
Assert.Equal(0.7m, accounting.Due);
Assert.Equal(1.2m, accounting.TotalDue);
entity.Payments.Add(new PaymentEntity()
{
Currency = "BTC",
Output = new TxOut(Money.Coins(0.2m), new Key()),
Accounted = true,
NetworkFee = 0.1m
});
entity.UpdateTotals();
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(0.6m), accounting.Due);
Assert.Equal(Money.Coins(1.3m), accounting.TotalDue);
Assert.Equal(0.6m, accounting.Due);
Assert.Equal(1.3m, accounting.TotalDue);
entity.Payments.Add(new PaymentEntity()
{
Currency = "BTC",
Output = new TxOut(Money.Coins(0.6m), new Key()),
Accounted = true,
NetworkFee = 0.1m
});
entity.UpdateTotals();
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Zero, accounting.Due);
Assert.Equal(Money.Coins(1.3m), accounting.TotalDue);
Assert.Equal(0.0m, accounting.Due);
Assert.Equal(1.3m, accounting.TotalDue);
entity.Payments.Add(
new PaymentEntity() { Output = new TxOut(Money.Coins(0.2m), new Key()), Accounted = true });
new PaymentEntity() { Currency = "BTC", Output = new TxOut(Money.Coins(0.2m), new Key()), Accounted = true });
entity.UpdateTotals();
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Zero, accounting.Due);
Assert.Equal(Money.Coins(1.3m), accounting.TotalDue);
Assert.Equal(0.0m, accounting.Due);
Assert.Equal(1.3m, accounting.TotalDue);
entity = new InvoiceEntity();
entity.Networks = networkProvider;
entity.Price = 5000;
PaymentMethodDictionary paymentMethods = new PaymentMethodDictionary();
paymentMethods.Add(
new PaymentMethod() { CryptoCode = "BTC", Rate = 1000, NextNetworkFee = Money.Coins(0.1m) });
new PaymentMethod() { Currency = "BTC", Rate = 1000, NextNetworkFee = Money.Coins(0.1m) });
paymentMethods.Add(
new PaymentMethod() { CryptoCode = "LTC", Rate = 500, NextNetworkFee = Money.Coins(0.01m) });
new PaymentMethod() { Currency = "LTC", Rate = 500, NextNetworkFee = Money.Coins(0.01m) });
entity.SetPaymentMethods(paymentMethods);
entity.Payments = new List<PaymentEntity>();
entity.UpdateTotals();
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike));
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(5.1m), accounting.Due);
Assert.Equal(5.1m, accounting.Due);
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike));
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(10.01m), accounting.TotalDue);
Assert.Equal(10.01m, accounting.TotalDue);
entity.Payments.Add(new PaymentEntity()
{
CryptoCode = "BTC",
Currency = "BTC",
Output = new TxOut(Money.Coins(1.0m), new Key()),
Accounted = true,
NetworkFee = 0.1m
});
entity.UpdateTotals();
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike));
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(4.2m), accounting.Due);
Assert.Equal(Money.Coins(1.0m), accounting.CryptoPaid);
Assert.Equal(Money.Coins(1.0m), accounting.Paid);
Assert.Equal(Money.Coins(5.2m), accounting.TotalDue);
Assert.Equal(4.2m, accounting.Due);
Assert.Equal(1.0m, accounting.CryptoPaid);
Assert.Equal(1.0m, accounting.Paid);
Assert.Equal(5.2m, accounting.TotalDue);
Assert.Equal(2, accounting.TxRequired);
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike));
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(10.01m + 0.1m * 2 - 2.0m /* 8.21m */), accounting.Due);
Assert.Equal(Money.Coins(0.0m), accounting.CryptoPaid);
Assert.Equal(Money.Coins(2.0m), accounting.Paid);
Assert.Equal(Money.Coins(10.01m + 0.1m * 2), accounting.TotalDue);
Assert.Equal(10.01m + 0.1m * 2 - 2.0m /* 8.21m */, accounting.Due);
Assert.Equal(0.0m, accounting.CryptoPaid);
Assert.Equal(2.0m, accounting.Paid);
Assert.Equal(10.01m + 0.1m * 2, accounting.TotalDue);
entity.Payments.Add(new PaymentEntity()
{
CryptoCode = "LTC",
Currency = "LTC",
Output = new TxOut(Money.Coins(1.0m), new Key()),
Accounted = true,
NetworkFee = 0.01m
});
entity.UpdateTotals();
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike));
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(4.2m - 0.5m + 0.01m / 2), accounting.Due);
Assert.Equal(Money.Coins(1.0m), accounting.CryptoPaid);
Assert.Equal(Money.Coins(1.5m), accounting.Paid);
Assert.Equal(Money.Coins(5.2m + 0.01m / 2), accounting.TotalDue); // The fee for LTC added
Assert.Equal(4.2m - 0.5m + 0.01m / 2, accounting.Due);
Assert.Equal(1.0m, accounting.CryptoPaid);
Assert.Equal(1.5m, accounting.Paid);
Assert.Equal(5.2m + 0.01m / 2, accounting.TotalDue); // The fee for LTC added
Assert.Equal(2, accounting.TxRequired);
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike));
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(8.21m - 1.0m + 0.01m), accounting.Due);
Assert.Equal(Money.Coins(1.0m), accounting.CryptoPaid);
Assert.Equal(Money.Coins(3.0m), accounting.Paid);
Assert.Equal(Money.Coins(10.01m + 0.1m * 2 + 0.01m), accounting.TotalDue);
Assert.Equal(8.21m - 1.0m + 0.01m, accounting.Due);
Assert.Equal(1.0m, accounting.CryptoPaid);
Assert.Equal(3.0m, accounting.Paid);
Assert.Equal(10.01m + 0.1m * 2 + 0.01m, accounting.TotalDue);
Assert.Equal(2, accounting.TxRequired);
var remaining = Money.Coins(4.2m - 0.5m + 0.01m / 2);
var remaining = Money.Coins(4.2m - 0.5m + 0.01m / 2.0m).ToDecimal(MoneyUnit.BTC);
entity.Payments.Add(new PaymentEntity()
{
CryptoCode = "BTC",
Output = new TxOut(remaining, new Key()),
Currency = "BTC",
Output = new TxOut(Money.Coins(remaining), new Key()),
Accounted = true,
NetworkFee = 0.1m
});
entity.UpdateTotals();
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike));
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Zero, accounting.Due);
Assert.Equal(Money.Coins(1.0m) + remaining, accounting.CryptoPaid);
Assert.Equal(Money.Coins(1.5m) + remaining, accounting.Paid);
Assert.Equal(Money.Coins(5.2m + 0.01m / 2), accounting.TotalDue);
Assert.Equal(0.0m, accounting.Due);
Assert.Equal(1.0m + remaining, accounting.CryptoPaid);
Assert.Equal(1.5m + remaining, accounting.Paid);
Assert.Equal(5.2m + 0.01m / 2, accounting.TotalDue);
Assert.Equal(accounting.Paid, accounting.TotalDue);
Assert.Equal(2, accounting.TxRequired);
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike));
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Zero, accounting.Due);
Assert.Equal(Money.Coins(1.0m), accounting.CryptoPaid);
Assert.Equal(Money.Coins(3.0m) + remaining * 2, accounting.Paid);
Assert.Equal(0.0m, accounting.Due);
Assert.Equal(1.0m, accounting.CryptoPaid);
Assert.Equal(3.0m + remaining * 2, accounting.Paid);
// Paying 2 BTC fee, LTC fee removed because fully paid
Assert.Equal(Money.Coins(10.01m + 0.1m * 2 + 0.1m * 2 /* + 0.01m no need to pay this fee anymore */),
Assert.Equal(10.01m + 0.1m * 2 + 0.1m * 2 /* + 0.01m no need to pay this fee anymore */,
accounting.TotalDue);
Assert.Equal(1, accounting.TxRequired);
Assert.Equal(accounting.Paid, accounting.TotalDue);
@ -548,27 +596,29 @@ namespace BTCPayServer.Tests
entity.Payments = new List<PaymentEntity>();
entity.SetPaymentMethod(new PaymentMethod()
{
CryptoCode = "BTC",
Currency = "BTC",
Rate = 5000,
NextNetworkFee = Money.Coins(0.1m)
});
entity.Price = 5000;
entity.PaymentTolerance = 0;
entity.UpdateTotals();
var paymentMethod = entity.GetPaymentMethods().TryGet("BTC", PaymentTypes.BTCLike);
var accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(1.1m), accounting.Due);
Assert.Equal(Money.Coins(1.1m), accounting.TotalDue);
Assert.Equal(Money.Coins(1.1m), accounting.MinimumTotalDue);
Assert.Equal(1.1m, accounting.Due);
Assert.Equal(1.1m, accounting.TotalDue);
Assert.Equal(1.1m, accounting.MinimumTotalDue);
entity.PaymentTolerance = 10;
entity.UpdateTotals();
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Coins(0.99m), accounting.MinimumTotalDue);
Assert.Equal(0.99m, accounting.MinimumTotalDue);
entity.PaymentTolerance = 100;
entity.UpdateTotals();
accounting = paymentMethod.Calculate();
Assert.Equal(Money.Satoshis(1), accounting.MinimumTotalDue);
Assert.Equal(0.0000_0001m, accounting.MinimumTotalDue);
}
[Fact]
@ -609,7 +659,7 @@ namespace BTCPayServer.Tests
}
[Fact]
public void CanDetectImage()
public void CanDetectFileType()
{
Assert.True(FileTypeDetector.IsPicture(new byte[] { 0x42, 0x4D }, "test.bmp"));
Assert.False(FileTypeDetector.IsPicture(new byte[] { 0x42, 0x4D }, ".bmp"));
@ -622,6 +672,15 @@ namespace BTCPayServer.Tests
Assert.False(FileTypeDetector.IsPicture(new byte[] { 0x3C, 0x73, 0x76, 0x67 }, "test.jpg"));
Assert.False(FileTypeDetector.IsPicture(new byte[] { 0xFF }, "e.jpg"));
Assert.False(FileTypeDetector.IsPicture(new byte[] { }, "empty.jpg"));
Assert.False(FileTypeDetector.IsPicture(new byte[] { 0x49, 0x44, 0x33, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x23 }, "music.mp3"));
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x49, 0x44, 0x33, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x23 }, "music.mp3"));
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x52, 0x49, 0x46, 0x46, 0x24, 0x9A, 0x08, 0x00, 0x57, 0x41 }, "music.wav"));
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0xFF, 0xF1, 0x50, 0x80, 0x1C, 0x3F, 0xFC, 0xDA, 0x00, 0x4C }, "music.aac"));
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x66, 0x4C, 0x61, 0x43, 0x00, 0x00, 0x00, 0x22, 0x04, 0x80 }, "music.flac"));
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x4F, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00 }, "music.ogg"));
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00 }, "music.weba"));
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0xFF, 0xF3, 0xE4, 0x64, 0x00, 0x20, 0xAD, 0xBD, 0x04, 0x00 }, "music.mp3"));
}
[Fact]
@ -1064,7 +1123,7 @@ namespace BTCPayServer.Tests
search = new SearchString(filter);
Assert.Equal("2019-04-25 01:00 AM", search.Filters["startdate"].First());
Assert.Equal("hekki", search.TextSearch);
// modify search
filter = $"status:settled,exceptionstatus:paidLate,unusual:true, fulltext searchterm, storeid:{storeId},startdate:2019-04-25 01:00:00";
search = new SearchString(filter);
@ -1074,33 +1133,33 @@ namespace BTCPayServer.Tests
Assert.Single(search.Filters["status"], "settled");
Assert.Single(search.Filters["exceptionstatus"], "paidLate");
Assert.Single(search.Filters["unusual"], "true");
// toggle off bool with same value
var modified = new SearchString(search.Toggle("unusual", "true"));
Assert.Null(modified.GetFilterBool("unusual"));
// add to array
modified = new SearchString(modified.Toggle("status", "processing"));
var statusArray = modified.GetFilterArray("status");
Assert.Equal(2, statusArray.Length);
Assert.Contains("processing", statusArray);
Assert.Contains("settled", statusArray);
// toggle off array with same value
modified = new SearchString(modified.Toggle("status", "settled"));
statusArray = modified.GetFilterArray("status");
Assert.Single(statusArray, "processing");
// toggle off array with null value
modified = new SearchString(modified.Toggle("status", null));
Assert.Null(modified.GetFilterArray("status"));
// toggle off date with null value
modified = new SearchString(modified.Toggle("startdate", "-7d"));
Assert.Single(modified.GetFilterArray("startdate"), "-7d");
modified = new SearchString(modified.Toggle("startdate", null));
Assert.Null(modified.GetFilterArray("startdate"));
// toggle off date with same value
modified = new SearchString(modified.Toggle("enddate", "-7d"));
Assert.Single(modified.GetFilterArray("enddate"), "-7d");
@ -1145,6 +1204,45 @@ namespace BTCPayServer.Tests
Assert.Equal("000000161", m.OrderId);
}
[Fact]
public void CanParseOldPosAppData()
{
var data = new JObject()
{
["price"] = 1.64m
}.ToString();
Assert.Equal(1.64m, JsonConvert.DeserializeObject<PosAppCartItem>(data).Price);
data = new JObject()
{
["price"] = new JObject()
{
["value"] = 1.65m
}
}.ToString();
Assert.Equal(1.65m, JsonConvert.DeserializeObject<PosAppCartItem>(data).Price);
data = new JObject()
{
["price"] = new JObject()
{
["value"] = "1.6305"
}
}.ToString();
Assert.Equal(1.6305m, JsonConvert.DeserializeObject<PosAppCartItem>(data).Price);
data = new JObject()
{
["price"] = new JObject()
{
["value"] = null
}
}.ToString();
Assert.Equal(0.0m, JsonConvert.DeserializeObject<PosAppCartItem>(data).Price);
var o = JObject.Parse(JsonConvert.SerializeObject(new PosAppCartItem() { Price = 1.356m }));
Assert.Equal(1.356m, o["price"].Value<decimal>());
}
[Fact]
public void CanParseCurrencyValue()
{
@ -1845,11 +1943,6 @@ namespace BTCPayServer.Tests
#pragma warning disable CS0618
var dummy = new Key().PubKey.GetAddress(ScriptPubKeyType.Legacy, Network.RegTest).ToString();
var networkProvider = new BTCPayNetworkProvider(ChainName.Regtest);
var paymentMethodHandlerDictionary = new PaymentMethodHandlerDictionary(new IPaymentMethodHandler[]
{
new BitcoinLikePaymentHandler(null, networkProvider, null, null, null, null),
new LightningLikePaymentHandler(null, null, networkProvider, null, null, null),
});
var networkBTC = networkProvider.GetNetwork("BTC");
var networkLTC = networkProvider.GetNetwork("LTC");
InvoiceEntity invoiceEntity = new InvoiceEntity();
@ -1857,14 +1950,14 @@ namespace BTCPayServer.Tests
invoiceEntity.Payments = new System.Collections.Generic.List<PaymentEntity>();
invoiceEntity.Price = 100;
PaymentMethodDictionary paymentMethods = new PaymentMethodDictionary();
paymentMethods.Add(new PaymentMethod() { Network = networkBTC, CryptoCode = "BTC", Rate = 10513.44m, }
paymentMethods.Add(new PaymentMethod() { Network = networkBTC, Currency = "BTC", Rate = 10513.44m, }
.SetPaymentMethodDetails(
new BTCPayServer.Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod()
{
NextNetworkFee = Money.Coins(0.00000100m),
DepositAddress = dummy
}));
paymentMethods.Add(new PaymentMethod() { Network = networkLTC, CryptoCode = "LTC", Rate = 216.79m }
paymentMethods.Add(new PaymentMethod() { Network = networkLTC, Currency = "LTC", Rate = 216.79m }
.SetPaymentMethodDetails(
new BTCPayServer.Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod()
{
@ -1880,7 +1973,7 @@ namespace BTCPayServer.Tests
new PaymentEntity()
{
Accounted = true,
CryptoCode = "BTC",
Currency = "BTC",
NetworkFee = 0.00000100m,
Network = networkProvider.GetNetwork("BTC"),
}
@ -1889,34 +1982,33 @@ namespace BTCPayServer.Tests
Network = networkProvider.GetNetwork("BTC"),
Output = new TxOut() { Value = Money.Coins(0.00151263m) }
}));
invoiceEntity.UpdateTotals();
accounting = btc.Calculate();
invoiceEntity.Payments.Add(
new PaymentEntity()
{
Accounted = true,
CryptoCode = "BTC",
Currency = "BTC",
NetworkFee = 0.00000100m,
Network = networkProvider.GetNetwork("BTC")
}
.SetCryptoPaymentData(new BitcoinLikePaymentData()
{
Network = networkProvider.GetNetwork("BTC"),
Output = new TxOut() { Value = accounting.Due }
Output = new TxOut() { Value = Money.Coins(accounting.Due) }
}));
invoiceEntity.UpdateTotals();
accounting = btc.Calculate();
Assert.Equal(Money.Zero, accounting.Due);
Assert.Equal(Money.Zero, accounting.DueUncapped);
Assert.Equal(0.0m, accounting.Due);
Assert.Equal(0.0m, accounting.DueUncapped);
var ltc = invoiceEntity.GetPaymentMethod(new PaymentMethodId("LTC", PaymentTypes.BTCLike));
accounting = ltc.Calculate();
Assert.Equal(Money.Zero, accounting.Due);
// LTC might have over paid due to BTC paying above what it should (round 1 satoshi up)
Assert.True(accounting.DueUncapped < Money.Zero);
var paymentMethod = InvoiceWatcher.GetNearestClearedPayment(paymentMethods, out var accounting2);
Assert.Equal(btc.CryptoCode, paymentMethod.CryptoCode);
#pragma warning restore CS0618
Assert.Equal(0.0m, accounting.Due);
// LTC might should be over paid due to BTC paying above what it should (round 1 satoshi up), but we handle this case
// and set DueUncapped to zero.
Assert.Equal(0.0m, accounting.DueUncapped);
}
[Fact]

@ -17,6 +17,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.PayoutProcessors.OnChain;
using BTCPayServer.Plugins;
using BTCPayServer.Services;
using BTCPayServer.Services.Custodian.Client.MockCustodian;
using BTCPayServer.Services.Notifications;
@ -1074,6 +1075,22 @@ namespace BTCPayServer.Tests
var lnrURLs = await unauthenticated.GetPullPaymentLNURL(test4.Id);
Assert.IsType<string>(lnrURLs.LNURLBech32);
Assert.IsType<string>(lnrURLs.LNURLUri);
Assert.Equal(12.303228134m, test4.Amount);
Assert.Equal("BTC", test4.Currency);
// Test with SATS denomination values
var testSats = await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest()
{
Name = "Test SATS",
Amount = 21000,
Currency = "SATS",
PaymentMethods = new[] { "BTC", "BTC-LightningNetwork", "BTC_LightningLike" }
});
lnrURLs = await unauthenticated.GetPullPaymentLNURL(testSats.Id);
Assert.IsType<string>(lnrURLs.LNURLBech32);
Assert.IsType<string>(lnrURLs.LNURLUri);
Assert.Equal(21000, testSats.Amount);
Assert.Equal("SATS", testSats.Currency);
//permission test around auto approved pps and payouts
var nonApproved = await acc.CreateClient(Policies.CanCreateNonApprovedPullPayments);
@ -1135,7 +1152,8 @@ namespace BTCPayServer.Tests
Approved = false,
PaymentMethod = "BTC",
Amount = 0.0001m,
Destination = address.ToString()
Destination = address.ToString(),
});
await AssertAPIError("invalid-state", async () =>
{
@ -2615,7 +2633,7 @@ namespace BTCPayServer.Tests
for (int i = 0; i < invoices.Length; i++)
{
pm[i] = Assert.Single(await client.GetInvoicePaymentMethods(user.StoreId, (await invoices[i]).Id));
Assert.False(pm[i].AdditionalData.HasValues);
Assert.True(pm[i].AdditionalData.HasValues);
}
// Pay them all at once
@ -3528,6 +3546,7 @@ namespace BTCPayServer.Tests
PaymentMethod = "BTC_LightningNetwork",
Destination = customerInvoice.BOLT11
});
Assert.Equal(payout.Metadata.ToString(), new JObject().ToString()); //empty
Assert.Empty(await adminClient.GetStoreLightningAutomatedPayoutProcessors(admin.StoreId, "BTC_LightningNetwork"));
await adminClient.UpdateStoreLightningAutomatedPayoutProcessors(admin.StoreId, "BTC_LightningNetwork",
new LightningAutomatedPayoutSettings() { IntervalSeconds = TimeSpan.FromSeconds(600) });
@ -3538,6 +3557,46 @@ namespace BTCPayServer.Tests
(await adminClient.GetStorePayouts(admin.StoreId, false)).Single(data => data.Id == payout.Id);
Assert.Equal(PayoutState.Completed, payoutC.State);
});
payout = await adminClient.CreatePayout(admin.StoreId,
new CreatePayoutThroughStoreRequest()
{
Approved = true,
PaymentMethod = "BTC",
Destination = (await tester.ExplorerNode.GetNewAddressAsync()).ToString(),
Amount = 0.0001m,
Metadata = JObject.FromObject(new
{
source ="apitest",
sourceLink = "https://chocolate.com"
})
});
Assert.Equal(payout.Metadata.ToString(), JObject.FromObject(new
{
source = "apitest",
sourceLink = "https://chocolate.com"
}).ToString());
payout =
(await adminClient.GetStorePayouts(admin.StoreId, false)).Single(data => data.Id == payout.Id);
Assert.Equal(payout.Metadata.ToString(), JObject.FromObject(new
{
source = "apitest",
sourceLink = "https://chocolate.com"
}).ToString());
customerInvoice = await tester.CustomerLightningD.CreateInvoice(LightMoney.FromUnit(10, LightMoneyUnit.Satoshi),
Guid.NewGuid().ToString(), TimeSpan.FromDays(40));
var payout2 = await adminClient.CreatePayout(admin.StoreId,
new CreatePayoutThroughStoreRequest()
{
Approved = true,
Amount = new Money(100, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC),
PaymentMethod = "BTC_LightningNetwork",
Destination = customerInvoice.BOLT11
});
Assert.Equal(payout2.Amount, new Money(100, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC));
}
[Fact(Timeout = 60 * 2 * 1000)]
@ -3653,9 +3712,12 @@ namespace BTCPayServer.Tests
Assert.Single(payouts.Where(data => data.State == PayoutState.InProgress));
});
var txid = await tester.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create((await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
tester.ExplorerClient.Network.NBitcoinNetwork), Money.Coins(0.01m) + fee);
await tester.WaitForEvent<NewOnChainTransactionEvent>(null, correctEvent: ev => ev.NewTransactionEvent.TransactionData.TransactionHash == txid);
uint256 txid = null;
await tester.WaitForEvent<NewOnChainTransactionEvent>(async () =>
{
txid = await tester.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create((await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
tester.ExplorerClient.Network.NBitcoinNetwork), Money.Coins(0.01m) + fee);
}, correctEvent: ev => ev.NewTransactionEvent.TransactionData.TransactionHash == txid);
await tester.PayTester.GetService<PayoutProcessorService>().Restart(new PayoutProcessorService.PayoutProcessorQuery(admin.StoreId, "BTC"));
await TestUtils.EventuallyAsync(async () =>
{
@ -3663,6 +3725,122 @@ namespace BTCPayServer.Tests
payouts = await adminClient.GetStorePayouts(admin.StoreId);
Assert.Empty(payouts.Where(data => data.State != PayoutState.InProgress));
});
// settings that were added later
var settings =
Assert.Single(await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC"));
Assert.False( settings.ProcessNewPayoutsInstantly);
Assert.Equal(0m, settings.Threshold);
//let's use the ProcessNewPayoutsInstantly so that it will trigger instantly
settings.IntervalSeconds = TimeSpan.FromDays(1);
settings.ProcessNewPayoutsInstantly = true;
await tester.WaitForEvent<NewOnChainTransactionEvent>(async () =>
{
txid = await tester.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create((await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
tester.ExplorerClient.Network.NBitcoinNetwork), Money.Coins(1m) + fee);
}, correctEvent: ev => ev.NewTransactionEvent.TransactionData.TransactionHash == txid);
await adminClient.UpdateStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC", settings);
settings =
Assert.Single(await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC"));
Assert.True( settings.ProcessNewPayoutsInstantly);
var pluginHookService = tester.PayTester.GetService<IPluginHookService>();
var beforeHookTcs = new TaskCompletionSource();
var afterHookTcs = new TaskCompletionSource();
pluginHookService.ActionInvoked += (sender, tuple) =>
{
switch (tuple.hook)
{
case "before-automated-payout-processing":
beforeHookTcs.TrySetResult();
break;
case "after-automated-payout-processing":
afterHookTcs.TrySetResult();
break;
}
};
var payoutThatShouldBeProcessedStraightAway = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest()
{
PullPaymentId = pullPayment.Id,
Amount = 0.5m,
Approved = true,
PaymentMethod = "BTC",
Destination = (await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
});
await beforeHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
await afterHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
payouts = await adminClient.GetStorePayouts(admin.StoreId);
Assert.Single(payouts.Where(data => data.State == PayoutState.InProgress && data.Id == payoutThatShouldBeProcessedStraightAway.Id));
beforeHookTcs = new TaskCompletionSource();
afterHookTcs = new TaskCompletionSource();
//let's test the threshold limiter
settings.Threshold = 0.5m;
await adminClient.UpdateStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC", settings);
//quick test: when updating processor, it processes instantly
await beforeHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
await afterHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
settings =
Assert.Single(await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC"));
Assert.Equal(0.5m, settings.Threshold);
//create a payout that should not be processed straight away due to threshold
beforeHookTcs = new TaskCompletionSource();
afterHookTcs = new TaskCompletionSource();
var payoutThatShouldNotBeProcessedStraightAway = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest()
{
Amount = 0.1m,
Approved = true,
PaymentMethod = "BTC",
Destination = (await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
});
await beforeHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
await afterHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
payouts = await adminClient.GetStorePayouts(admin.StoreId);
Assert.Single(payouts.Where(data => data.State == PayoutState.AwaitingPayment && data.Id == payoutThatShouldNotBeProcessedStraightAway.Id));
beforeHookTcs = new TaskCompletionSource();
afterHookTcs = new TaskCompletionSource();
var payoutThatShouldNotBeProcessedStraightAway2 = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest()
{
Amount = 0.3m,
Approved = true,
PaymentMethod = "BTC",
Destination = (await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
});
await beforeHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
await afterHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
payouts = await adminClient.GetStorePayouts(admin.StoreId);
Assert.Equal(2, payouts.Count(data => data.State == PayoutState.AwaitingPayment &&
(data.Id == payoutThatShouldNotBeProcessedStraightAway.Id || data.Id == payoutThatShouldNotBeProcessedStraightAway2.Id)));
beforeHookTcs = new TaskCompletionSource();
afterHookTcs = new TaskCompletionSource();
var payoutThatShouldNotBeProcessedStraightAway3 = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest()
{
Amount = 0.3m,
Approved = true,
PaymentMethod = "BTC",
Destination = (await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
});
await beforeHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
await afterHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
payouts = await adminClient.GetStorePayouts(admin.StoreId);
Assert.Empty(payouts.Where(data => data.State != PayoutState.InProgress));
}
[Fact(Timeout = 60 * 2 * 1000)]

@ -135,10 +135,10 @@ donation:
Assert.Equal("donation", vmview.Items[1].Title);
// orange is available
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 0, null, null, null, null, "orange").Result);
.ViewPointOfSale(app.Id, PosViewType.Cart, 0, choiceKey: "orange").Result);
// apple is not found
Assert.IsType<NotFoundResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 0, null, null, null, null, "apple").Result);
.ViewPointOfSale(app.Id, PosViewType.Cart, 0, choiceKey: "apple").Result);
}
}
}

@ -393,6 +393,10 @@ namespace BTCPayServer.Tests
public void GoToHome()
{
Driver.Navigate().GoToUrl(ServerUri);
if (Driver.PageSource.Contains("id=\"SkipWizard\""))
{
Driver.FindElement(By.Id("SkipWizard")).Click();
}
}
public void Logout()

@ -1,5 +1,6 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
@ -56,10 +57,11 @@ namespace BTCPayServer.Tests
using var s = CreateSeleniumTester();
await s.StartAsync();
s.RegisterNewUser(true);
s.Driver.FindElement(By.Id("Nav-ServerSettings")).Click();
s.GoToHome();
s.GoToServer();
s.Driver.AssertNoError();
s.ClickOnAllSectionLinks();
s.Driver.FindElement(By.Id("Nav-ServerSettings")).Click();
s.GoToServer();
s.Driver.FindElement(By.LinkText("Services")).Click();
TestLogs.LogInformation("Let's check if we can access the logs");
@ -246,7 +248,8 @@ namespace BTCPayServer.Tests
s.Server.ActivateLightning();
await s.StartAsync();
s.RegisterNewUser(true);
s.Driver.FindElement(By.Id("Nav-ServerSettings")).Click();
s.GoToHome();
s.GoToServer();
s.Driver.AssertNoError();
s.Driver.FindElement(By.LinkText("Services")).Click();
@ -313,6 +316,7 @@ namespace BTCPayServer.Tests
await s.StartAsync();
//Register & Log Out
var email = s.RegisterNewUser();
s.GoToHome();
s.Logout();
s.Driver.AssertNoError();
Assert.Contains("/login", s.Driver.Url);
@ -348,6 +352,7 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("Password")).SendKeys("abc???");
s.Driver.FindElement(By.Id("LoginButton")).Click();
s.GoToHome();
s.GoToProfile();
s.ClickOnAllSectionLinks();
@ -355,6 +360,7 @@ namespace BTCPayServer.Tests
s.Logout();
s.GoToRegister();
s.RegisterNewUser(true);
s.GoToHome();
s.GoToServer(ServerNavPages.Users);
s.Driver.FindElement(By.Id("CreateUser")).Click();
@ -377,6 +383,7 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("LoginButton")).Click();
// We should be logged in now
s.GoToHome();
s.Driver.FindElement(By.Id("mainNav"));
//let's test delete user quickly while we're at it
@ -641,7 +648,7 @@ namespace BTCPayServer.Tests
// verify redirected to create store page
Assert.EndsWith("/stores/create", s.Driver.Url);
Assert.Contains("Create your first store", s.Driver.PageSource);
Assert.Contains("To start accepting payments, set up a store.", s.Driver.PageSource);
Assert.Contains("Create a store to begin accepting payments", s.Driver.PageSource);
Assert.False(s.Driver.PageSource.Contains("id=\"StoreSelectorDropdown\""), "Store selector dropdown should not be present");
(_, string storeId) = s.CreateNewStore();
@ -961,11 +968,13 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Cart']")).Click();
s.Driver.FindElement(By.CssSelector(".template-item:nth-of-type(1) .btn-primary")).Click();
s.Driver.FindElement(By.Id("BuyButtonText")).SendKeys("Take my money");
s.Driver.FindElement(By.Id("EditorCategories-ts-control")).SendKeys("Drinks");
s.Driver.FindElement(By.Id("SaveItemChanges")).Click();
s.Driver.FindElement(By.Id("ToggleRawEditor")).Click();
var template = s.Driver.FindElement(By.Id("Template")).GetAttribute("value");
Assert.Contains("\"buyButtonText\":\"Take my money\"", template);
Assert.Contains("\"buyButtonText\": \"Take my money\"", template);
Assert.Matches("\"categories\": \\[\n\\s+\"Drinks\"\n\\s+\\]", template);
s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text);
@ -979,6 +988,14 @@ namespace BTCPayServer.Tests
Assert.True(s.Driver.PageSource.Contains("Tea shop"), "Unable to create PoS");
Assert.True(s.Driver.PageSource.Contains("Cart"), "PoS not showing correct default view");
Assert.True(s.Driver.PageSource.Contains("Take my money"), "PoS not showing correct default view");
Assert.Equal(5, s.Driver.FindElements(By.CssSelector(".posItem:not(.d-none)")).Count);
var drinks = s.Driver.FindElement(By.CssSelector("label[for='Category-Drinks']"));
Assert.Equal("Drinks", drinks.Text);
drinks.Click();
Assert.Single(s.Driver.FindElements(By.CssSelector(".posItem:not(.d-none)")));
s.Driver.FindElement(By.CssSelector("label[for='Category-*']")).Click();
Assert.Equal(5, s.Driver.FindElements(By.CssSelector(".posItem:not(.d-none)")).Count);
s.Driver.Url = posBaseUrl + "/static";
Assert.False(s.Driver.PageSource.Contains("Cart"), "Static PoS not showing correct view");
@ -1145,12 +1162,13 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("ArchivePaymentRequest")).Click();
Assert.Contains("The payment request has been archived", s.FindAlertMessage().Text);
Assert.DoesNotContain("Pay123", s.Driver.PageSource);
s.Driver.FindElement(By.Id("SearchDropdownToggle")).Click();
s.Driver.FindElement(By.Id("SearchIncludeArchived")).Click();
s.Driver.FindElement(By.Id("StatusOptionsToggle")).Click();
s.Driver.WaitForElement(By.Id("StatusOptionsIncludeArchived")).Click();
Assert.Contains("Pay123", s.Driver.PageSource);
// unarchive (from list)
s.Driver.FindElement(By.Id($"ToggleArchival-{payReqId}")).Click();
s.Driver.FindElement(By.Id($"ToggleActions-{payReqId}")).Click();
s.Driver.WaitForElement(By.Id($"ToggleArchival-{payReqId}")).Click();
Assert.Contains("The payment request has been unarchived", s.FindAlertMessage().Text);
Assert.Contains("Pay123", s.Driver.PageSource);
}
@ -1441,7 +1459,7 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("CancelWizard")).Click();
// Check the label is applied to the tx
s.Driver.WaitWalletTransactionsLoaded();
Assert.Equal("label2", s.Driver.FindElement(By.XPath("//*[@id=\"WalletTransactionsList\"]//*[contains(@class, 'transaction-label')]")).Text);
//change the wallet and ensure old address is not there and generating a new one does not result in the prev one
@ -1492,7 +1510,9 @@ namespace BTCPayServer.Tests
// Check the tx sent earlier arrived
s.Driver.FindElement(By.Id($"StoreNav-Wallet{cryptoCode}")).Click();
Assert.Contains(tx.ToString(), s.Driver.PageSource);
s.Driver.WaitWalletTransactionsLoaded();
s.Driver.FindElement(By.PartialLinkText(tx.ToString()));
var walletTransactionUri = new Uri(s.Driver.Url);
// Send to bob
@ -1618,9 +1638,8 @@ namespace BTCPayServer.Tests
// Transactions list is empty
s.Driver.FindElement(By.Id($"StoreNav-Wallet{cryptoCode}")).Click();
Assert.Contains("There are no transactions yet.", s.Driver.PageSource);
s.Driver.AssertElementNotFound(By.Id("ExportDropdownToggle"));
s.Driver.AssertElementNotFound(By.Id("ActionsDropdownToggle"));
s.Driver.WaitWalletTransactionsLoaded();
Assert.Contains("There are no transactions yet", s.Driver.FindElement(By.Id("WalletTransactions")).Text);
}
[Fact]
@ -1748,7 +1767,6 @@ namespace BTCPayServer.Tests
Assert.Contains(labels, element => element.Text == "pull-payment");
});
s.GoToStore(s.StoreId, StoreNavPages.Payouts);
s.Driver.FindElement(By.Id($"{PayoutState.InProgress}-view")).Click();
ReadOnlyCollection<IWebElement> txs;
@ -1932,8 +1950,7 @@ namespace BTCPayServer.Tests
Assert.Contains(PayoutState.AwaitingPayment.GetStateString(), s.Driver.PageSource);
//lnurl-w support check
// LNURL Withdraw support check with BTC denomination
s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
s.Driver.FindElement(By.Id("NewPullPayment")).Click();
s.Driver.FindElement(By.Id("Name")).SendKeys("PP1");
@ -2001,6 +2018,42 @@ namespace BTCPayServer.Tests
Assert.Contains(PayoutState.AwaitingApproval.GetStateString(), s.Driver.PageSource);
});
// LNURL Withdraw support check with SATS denomination
s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
s.Driver.FindElement(By.Id("NewPullPayment")).Click();
s.Driver.FindElement(By.Id("Name")).SendKeys("PP SATS");
s.Driver.SetCheckbox(By.Id("AutoApproveClaims"), true);
s.Driver.FindElement(By.Id("Amount")).Clear();
s.Driver.FindElement(By.Id("Amount")).SendKeys("21021");
s.Driver.FindElement(By.Id("Currency")).Clear();
s.Driver.FindElement(By.Id("Currency")).SendKeys("SATS" + Keys.Enter);
s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success);
s.Driver.FindElement(By.LinkText("View")).Click();
s.Driver.FindElement(By.CssSelector("#lnurlwithdraw-button")).Click();
lnurl = new Uri(LNURL.LNURL.Parse(s.Driver.FindElement(By.Id("qr-code-data-input")).GetAttribute("value"), out _).ToString().Replace("https", "http"));
s.Driver.FindElement(By.CssSelector("button[data-bs-dismiss='modal']")).Click();
var amount = new LightMoney(21021, LightMoneyUnit.Satoshi);
info = Assert.IsType<LNURLWithdrawRequest>(await LNURL.LNURL.FetchInformation(lnurl, s.Server.PayTester.HttpClient));
Assert.Equal(amount, info.MaxWithdrawable);
Assert.Equal(amount, info.CurrentBalance);
info = Assert.IsType<LNURLWithdrawRequest>(await LNURL.LNURL.FetchInformation(info.BalanceCheck, s.Server.PayTester.HttpClient));
Assert.Equal(amount, info.MaxWithdrawable);
Assert.Equal(amount, info.CurrentBalance);
bolt2 = (await s.Server.CustomerLightningD.CreateInvoice(
amount,
$"LNurl w payout test {DateTime.UtcNow.Ticks}",
TimeSpan.FromHours(1), CancellationToken.None));
response = await info.SendRequest(bolt2.BOLT11, s.Server.PayTester.HttpClient);
await TestUtils.EventuallyAsync(async () =>
{
s.Driver.Navigate().Refresh();
Assert.Contains(bolt2.BOLT11, s.Driver.PageSource);
Assert.Contains(PayoutState.Completed.GetStateString(), s.Driver.PageSource);
Assert.Equal(LightningInvoiceStatus.Paid, (await s.Server.CustomerLightningD.GetInvoice(bolt2.Id)).Status);
});
}
[Fact]
@ -2040,6 +2093,150 @@ namespace BTCPayServer.Tests
}
}
[Fact]
[Trait("Selenium", "Selenium")]
[Trait("Lightning", "Lightning")]
public async Task CanUsePOSKeypad()
{
using var s = CreateSeleniumTester();
s.Server.ActivateLightning();
await s.StartAsync();
await s.Server.EnsureChannelsSetup();
s.RegisterNewUser(true);
s.CreateNewStore();
s.GoToStore();
s.AddLightningNode(LightningConnectionType.CLightning, false);
s.Driver.FindElement(By.Id("StoreNav-CreatePointOfSale")).Click();
s.Driver.FindElement(By.Id("AppName")).SendKeys(Guid.NewGuid().ToString());
s.Driver.FindElement(By.Id("Create")).Click();
TestUtils.Eventually(() => Assert.Contains("App successfully created", s.FindAlertMessage().Text));
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Light']")).Click();
s.Driver.FindElement(By.Id("Currency")).SendKeys("EUR");
s.Driver.FindElement(By.Id("CustomTipPercentages")).Clear();
s.Driver.FindElement(By.Id("CustomTipPercentages")).SendKeys("10,21");
s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text);
s.Driver.FindElement(By.Id("ViewApp")).Click();
var windows = s.Driver.WindowHandles;
Assert.Equal(2, windows.Count);
s.Driver.SwitchTo().Window(windows[1]);
s.Driver.WaitForElement(By.ClassName("keypad"));
// basic checks
Assert.Contains("EUR", s.Driver.FindElement(By.Id("Currency")).Text);
Assert.Contains("0,00", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.Equal("", s.Driver.FindElement(By.Id("Calculation")).Text);
Assert.True(s.Driver.FindElement(By.Id("ModeTablist-amount")).Selected);
Assert.False(s.Driver.FindElement(By.Id("ModeTablist-discount")).Enabled);
Assert.False(s.Driver.FindElement(By.Id("ModeTablist-tip")).Enabled);
// Amount: 1234,56
s.Driver.FindElement(By.CssSelector(".keypad [data-key='1']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='2']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='3']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='4']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='.']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='5']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='6']")).Click();
Assert.Equal("1.234,56", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.True(s.Driver.FindElement(By.Id("ModeTablist-discount")).Enabled);
Assert.True(s.Driver.FindElement(By.Id("ModeTablist-tip")).Enabled);
Assert.Equal("", s.Driver.FindElement(By.Id("Calculation")).Text);
// Discount: 10%
s.Driver.FindElement(By.CssSelector("label[for='ModeTablist-discount']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='1']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='0']")).Click();
Assert.Contains("1.111,10", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.Contains("10% discount", s.Driver.FindElement(By.Id("Discount")).Text);
Assert.Contains("1.234,56 € - 123,46 € (10%)", s.Driver.FindElement(By.Id("Calculation")).Text);
// Tip: 10%
s.Driver.FindElement(By.CssSelector("label[for='ModeTablist-tip']")).Click();
s.Driver.WaitForElement(By.Id("Tip-Custom"));
s.Driver.FindElement(By.Id("Tip-10")).Click();
Assert.Contains("1.222,21", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.Contains("1.234,56 € - 123,46 € (10%) + 111,11 € (10%)", s.Driver.FindElement(By.Id("Calculation")).Text);
// Pay
s.Driver.FindElement(By.Id("pay-button")).Click();
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
s.Driver.FindElement(By.Id("DetailsToggle")).Click();
s.Driver.WaitForElement(By.Id("PaymentDetails-TotalFiat"));
Assert.Contains("1 222,21 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
}
[Fact]
[Trait("Selenium", "Selenium")]
[Trait("Lightning", "Lightning")]
public async Task CanUsePOSCart()
{
using var s = CreateSeleniumTester();
s.Server.ActivateLightning();
await s.StartAsync();
await s.Server.EnsureChannelsSetup();
s.RegisterNewUser(true);
s.CreateNewStore();
s.GoToStore();
s.AddLightningNode(LightningConnectionType.CLightning, false);
s.Driver.FindElement(By.Id("StoreNav-CreatePointOfSale")).Click();
s.Driver.FindElement(By.Id("AppName")).SendKeys(Guid.NewGuid().ToString());
s.Driver.FindElement(By.Id("Create")).Click();
Assert.Contains("App successfully created", s.FindAlertMessage().Text);
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Cart']")).Click();
s.Driver.FindElement(By.Id("Currency")).SendKeys("EUR");
s.Driver.FindElement(By.Id("CustomTipPercentages")).Clear();
s.Driver.FindElement(By.Id("CustomTipPercentages")).SendKeys("10,21");
s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text);
s.Driver.FindElement(By.Id("ViewApp")).Click();
var windows = s.Driver.WindowHandles;
Assert.Equal(2, windows.Count);
s.Driver.SwitchTo().Window(windows[1]);
s.Driver.WaitForElement(By.Id("PosItems"));
Assert.Empty(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
// Select and clear
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(1) .btn-primary")).Click();
Assert.Single(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
s.Driver.FindElement(By.Id("CartClear")).Click();
Thread.Sleep(250);
Assert.Empty(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
// Select items
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(2) .btn-primary")).Click();
Thread.Sleep(250);
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(1) .btn-primary")).Click();
Thread.Sleep(250);
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(2) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Equal(2, s.Driver.FindElements(By.CssSelector("#CartItems tr")).Count);
Assert.Equal("3,00 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Discount: 10%
s.Driver.ElementDoesNotExist(By.Id("CartDiscount"));
s.Driver.FindElement(By.Id("Discount")).SendKeys("10");
Assert.Contains("10% = 0,30 €", s.Driver.FindElement(By.Id("CartDiscount")).Text);
Assert.Equal("2,70 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Tip: 10%
s.Driver.ElementDoesNotExist(By.Id("CartTip"));
s.Driver.FindElement(By.Id("Tip-10")).Click();
Assert.Contains("10% = 0,27 €", s.Driver.FindElement(By.Id("CartTip")).Text);
Assert.Equal("2,97 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Pay
s.Driver.FindElement(By.Id("CartSubmit")).Click();
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
s.Driver.FindElement(By.Id("DetailsToggle")).Click();
s.Driver.WaitForElement(By.Id("PaymentDetails-TotalFiat"));
Assert.Contains("2,97 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
}
[Fact]
[Trait("Selenium", "Selenium")]
[Trait("Lightning", "Lightning")]
@ -2269,7 +2466,7 @@ namespace BTCPayServer.Tests
var addresses = s.Driver.FindElements(By.ClassName("lightning-address-value"));
Assert.Equal(2, addresses.Count);
var callbacks = new List<Uri>();
foreach (IWebElement webElement in addresses)
{
var value = webElement.GetAttribute("value");
@ -2287,6 +2484,7 @@ namespace BTCPayServer.Tests
lnaddress2 = m["text/identifier"];
Assert.Equal(2, request.MinSendable.ToDecimal(LightMoneyUnit.Satoshi));
Assert.Equal(10, request.MaxSendable.ToDecimal(LightMoneyUnit.Satoshi));
callbacks.Add(request.Callback);
break;
case { } v when v.StartsWith(lnaddress1):
@ -2294,6 +2492,7 @@ namespace BTCPayServer.Tests
lnaddress1 = m["text/identifier"];
Assert.Equal(1, request.MinSendable.ToDecimal(LightMoneyUnit.Satoshi));
Assert.Equal(6.12m, request.MaxSendable.ToDecimal(LightMoneyUnit.BTC));
callbacks.Add(request.Callback);
break;
default:
Assert.False(true, "Should have matched");
@ -2301,7 +2500,19 @@ namespace BTCPayServer.Tests
}
}
var repo = s.Server.PayTester.GetService<InvoiceRepository>();
var invoices = await repo.GetInvoices(new InvoiceQuery() { StoreId = new[] { s.StoreId } });
// Resolving a ln address shouldn't create any btcpay invoice.
// This must be done because some NOST clients resolve ln addresses preemptively without user interaction
Assert.Empty(invoices);
// Calling the callbacks should create the invoices
foreach (var callback in callbacks)
{
using var r = await s.Server.PayTester.HttpClient.GetAsync(callback);
await r.Content.ReadAsStringAsync();
}
invoices = await repo.GetInvoices(new InvoiceQuery() { StoreId = new[] { s.StoreId } });
Assert.Equal(2, invoices.Length);
var emailSuffix = $"@{s.Server.PayTester.HostName}:{s.Server.PayTester.Port}";
foreach (var i in invoices)
@ -2383,6 +2594,7 @@ namespace BTCPayServer.Tests
using var s = CreateSeleniumTester();
await s.StartAsync();
var user = s.RegisterNewUser();
s.GoToHome();
s.GoToProfile(ManageNavPages.LoginCodes);
var code = s.Driver.FindElement(By.Id("logincode")).GetAttribute("value");
s.Driver.FindElement(By.Id("regeneratecode")).Click();
@ -2394,14 +2606,12 @@ namespace BTCPayServer.Tests
s.Driver.SetAttribute("LoginCode", "value", "bad code");
s.Driver.InvokeJSFunction("logincode-form", "submit");
s.Driver.SetAttribute("LoginCode", "value", code);
s.Driver.InvokeJSFunction("logincode-form", "submit");
s.GoToProfile();
s.GoToHome();
Assert.Contains(user, s.Driver.PageSource);
}
// For god know why, selenium have problems clicking on the save button, resulting in ultimate hacks
// to make it works.
private void SudoForceSaveLightningSettingsRightNowAndFast(SeleniumTester s, string cryptoCode)
@ -2420,7 +2630,6 @@ retry:
}
}
[Fact]
[Trait("Selenium", "Selenium")]
public async Task CanUseLNURLAuth()
@ -2428,6 +2637,7 @@ retry:
using var s = CreateSeleniumTester();
await s.StartAsync();
var user = s.RegisterNewUser(true);
s.GoToHome();
s.GoToProfile(ManageNavPages.TwoFactorAuthentication);
s.Driver.FindElement(By.Name("Name")).SendKeys("ln wallet");
s.Driver.FindElement(By.Name("type"))
@ -2476,7 +2686,8 @@ retry:
{
using var s = CreateSeleniumTester(newDb: true);
await s.StartAsync();
var user = s.RegisterNewUser(true);
s.RegisterNewUser(true);
s.GoToHome();
s.GoToServer(ServerNavPages.Roles);
var existingServerRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
Assert.Equal(3, existingServerRoles.Count);

@ -40,6 +40,7 @@ namespace BTCPayServer.Tests
public class TestAccount
{
readonly ServerTester parent;
public string LNAddress;
public TestAccount(ServerTester parent)
{
@ -242,7 +243,7 @@ namespace BTCPayServer.Tests
policies.LockSubscription = false;
await account.Register(RegisterDetails);
}
TestLogs.LogInformation($"UserId: {account.RegisteredUserId} Password: {Password}");
UserId = account.RegisteredUserId;
Email = RegisterDetails.Email;
IsAdmin = account.RegisteredAdmin;
@ -309,8 +310,9 @@ namespace BTCPayServer.Tests
Assert.False(true, storeController.ModelState.FirstOrDefault().Value.Errors[0].ErrorMessage);
}
public async Task<Coin> ReceiveUTXO(Money value, BTCPayNetwork network)
public async Task<Coin> ReceiveUTXO(Money value, BTCPayNetwork network = null)
{
network ??= SupportedNetwork;
var cashCow = parent.ExplorerNode;
var btcPayWallet = parent.PayTester.GetService<BTCPayWalletProvider>().GetWallet(network);
var address = (await btcPayWallet.ReserveAddressAsync(this.DerivationScheme)).Address;
@ -553,5 +555,94 @@ retry:
var repo = this.parent.PayTester.GetService<StoreRepository>();
await repo.AddStoreUser(StoreId, userId, StoreRoleId.Owner);
}
public async Task<uint256> PayOnChain(string invoiceId)
{
var cryptoCode = "BTC";
var client = await CreateClient();
var methods = await client.GetInvoicePaymentMethods(StoreId, invoiceId);
var method = methods.First(m => m.PaymentMethod == cryptoCode);
var address = method.Destination;
var tx = await client.CreateOnChainTransaction(StoreId, cryptoCode, new CreateOnChainTransactionRequest()
{
Destinations = new List<CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination>()
{
new ()
{
Destination = address,
Amount = method.Due
}
},
FeeRate = new FeeRate(1.0m)
});
await WaitInvoicePaid(invoiceId);
return tx.TransactionHash;
}
public async Task PayOnBOLT11(string invoiceId)
{
var cryptoCode = "BTC";
var client = await CreateClient();
var methods = await client.GetInvoicePaymentMethods(StoreId, invoiceId);
var method = methods.First(m => m.PaymentMethod == $"{cryptoCode}-LightningNetwork");
var bolt11 = method.Destination;
TestLogs.LogInformation("PAYING");
await parent.CustomerLightningD.Pay(bolt11);
TestLogs.LogInformation("PAID");
await WaitInvoicePaid(invoiceId);
}
public async Task PayOnLNUrl(string invoiceId)
{
var cryptoCode = "BTC";
var network = SupportedNetwork.NBitcoinNetwork;
var client = await CreateClient();
var methods = await client.GetInvoicePaymentMethods(StoreId, invoiceId);
var method = methods.First(m => m.PaymentMethod == $"{cryptoCode}-LNURLPAY");
var lnurL = LNURL.LNURL.Parse(method.PaymentLink, out var tag);
var http = new HttpClient();
var payreq = (LNURL.LNURLPayRequest)await LNURL.LNURL.FetchInformation(lnurL, tag, http);
var resp = await payreq.SendRequest(payreq.MinSendable, network, http);
var bolt11 = resp.Pr;
await parent.CustomerLightningD.Pay(bolt11);
await WaitInvoicePaid(invoiceId);
}
public Task WaitInvoicePaid(string invoiceId)
{
return TestUtils.EventuallyAsync(async () =>
{
var client = await CreateClient();
var invoice = await client.GetInvoice(StoreId, invoiceId);
if (invoice.Status == InvoiceStatus.Settled)
return;
Assert.Equal(InvoiceStatus.Processing, invoice.Status);
});
}
public async Task PayOnLNAddress(string lnAddrUser = null)
{
lnAddrUser ??= LNAddress;
var network = SupportedNetwork.NBitcoinNetwork;
var payReqStr = await (await parent.PayTester.HttpClient.GetAsync($".well-known/lnurlp/{lnAddrUser}")).Content.ReadAsStringAsync();
var payreq = JsonConvert.DeserializeObject<LNURL.LNURLPayRequest>(payReqStr);
var resp = await payreq.SendRequest(payreq.MinSendable, network, parent.PayTester.HttpClient);
var bolt11 = resp.Pr;
await parent.CustomerLightningD.Pay(bolt11);
}
public async Task<string> CreateLNAddress()
{
var lnAddrUser = Guid.NewGuid().ToString();
var ctx = parent.PayTester.GetService<ApplicationDbContextFactory>().CreateContext();
ctx.LightningAddresses.Add(new()
{
StoreDataId = StoreId,
Username = lnAddrUser
});
await ctx.SaveChangesAsync();
LNAddress = lnAddrUser;
return lnAddrUser;
}
}
}

@ -290,9 +290,9 @@ retry:
}
[Fact]
public void CanGetRateCryptoCurrenciesByDefault()
public async Task CanGetRateCryptoCurrenciesByDefault()
{
string[] brokenShitcoins = { "BTX_USD", "CHC_USD" };
string[] brokenShitcoins = { };
var provider = new BTCPayNetworkProvider(ChainName.Mainnet);
var factory = FastTests.CreateBTCPayRateFactory();
var fetcher = new RateFetcher(factory);
@ -305,17 +305,37 @@ retry:
var result = fetcher.FetchRates(pairs, rules, default);
foreach ((CurrencyPair key, Task<RateResult> value) in result)
{
var rateResult = value.GetAwaiter().GetResult();
if (key.ToString() == "BTG_USD")
continue; // shitcoin not supported by bitfinex anymore
var rateResult = await value;
TestLogs.LogInformation($"Testing {key}");
if (brokenShitcoins.Contains(key.ToString()))
continue;
Assert.True(rateResult.BidAsk != null, $"Impossible to get the rate {rateResult.EvaluatedRule}");
}
var b = new StoreBlob();
foreach (var k in StoreBlob.RecommendedExchanges)
{
b.DefaultCurrency = k.Key;
rules = b.GetDefaultRateRules(provider);
pairs =
provider.GetAll()
.Select(c => new CurrencyPair(c.CryptoCode, k.Key))
.ToHashSet();
result = fetcher.FetchRates(pairs, rules, default);
foreach ((CurrencyPair key, Task<RateResult> value) in result)
{
var rateResult = await value;
TestLogs.LogInformation($"Testing {key} when default currency is {k.Key}");
if (brokenShitcoins.Contains(key.ToString()))
continue;
Assert.True(rateResult.BidAsk != null, $"Impossible to get the rate {rateResult.EvaluatedRule}");
}
}
}
[Fact]
[Trait("Fast", "Fast")]
public async Task CheckJsContent()
{
// This test verify that no malicious js is added in the minified files.
@ -324,47 +344,71 @@ retry:
var actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "bootstrap", "bootstrap.bundle.min.js").Trim();
var version = Regex.Match(actual, "Bootstrap v([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value;
var expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/bootstrap@{version}/dist/js/bootstrap.bundle.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "clipboard.js", "clipboard.js");
expected = (await (await client.GetAsync("https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.8/clipboard.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "vuejs", "vue.min.js").Trim();
version = Regex.Match(actual, "Vue\\.js v([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value;
expected = (await (await client.GetAsync($"https://cdnjs.cloudflare.com/ajax/libs/vue/{version}/vue.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "i18next", "i18next.min.js").Trim();
expected = (await (await client.GetAsync("https://cdnjs.cloudflare.com/ajax/libs/i18next/22.0.6/i18next.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "i18next", "i18nextHttpBackend.min.js").Trim();
expected = (await (await client.GetAsync("https://cdnjs.cloudflare.com/ajax/libs/i18next-http-backend/2.0.1/i18nextHttpBackend.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "i18next", "vue-i18next.js").Trim();
expected = (await (await client.GetAsync("https://unpkg.com/@panter/vue-i18next@0.15.2/dist/vue-i18next.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "vue-qrcode", "vue-qrcode.min.js").Trim();
version = Regex.Match(actual, "vue-qrcode v([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value;
expected = (await (await client.GetAsync($"https://unpkg.com/@chenfengyuan/vue-qrcode@{version}/dist/vue-qrcode.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "tom-select", "tom-select.complete.min.js").Trim();
expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "dom-confetti", "dom-confetti.min.js").Trim();
version = Regex.Match(actual, "Original file: /npm/dom-confetti@([0-9]+.[0-9]+.[0-9]+)/lib/main.js").Groups[1].Value;
expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/dom-confetti@{version}/lib/main.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "vue-sortable", "sortable.min.js").Trim();
version = Regex.Match(actual, "Sortable ([0-9]+.[0-9]+.[0-9]+) ").Groups[1].Value;
expected = (await (await client.GetAsync($"https://unpkg.com/sortablejs@{version}/Sortable.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "bootstrap-vue", "bootstrap-vue.min.js").Trim();
version = Regex.Match(actual, "BootstrapVue ([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value;
expected = (await (await client.GetAsync($"https://cdnjs.cloudflare.com/ajax/libs/bootstrap-vue/{version}/bootstrap-vue.min.js")).Content.ReadAsStringAsync()).Trim();
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "FileSaver", "FileSaver.min.js").Trim();
expected = (await (await client.GetAsync($"https://raw.githubusercontent.com/eligrey/FileSaver.js/43bbd2f0ae6794f8d452cd360e9d33aef6071234/dist/FileSaver.min.js")).Content.ReadAsStringAsync()).Trim();
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "papaparse", "papaparse.min.js").Trim();
expected = (await (await client.GetAsync($"https://raw.githubusercontent.com/mholt/PapaParse/5.4.1/papaparse.min.js")).Content.ReadAsStringAsync()).Trim();
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "vue-sanitize-directive", "vue-sanitize-directive.umd.min.js").Trim();
version = Regex.Match(actual, "Original file: /npm/vue-sanitize-directive@([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value;
expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/vue-sanitize-directive@{version}/dist/vue-sanitize-directive.umd.min.js")).Content.ReadAsStringAsync()).Trim();
EqualJsContent(expected, actual);
}
private void EqualJsContent(string expected, string actual)
{
if (expected != actual)
Assert.Equal(expected, actual.ReplaceLineEndings("\n"));
}
string GetFileContent(params string[] path)

@ -39,6 +39,7 @@ using BTCPayServer.Plugins.PayButton;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Security.Bitpay;
using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
@ -718,7 +719,7 @@ namespace BTCPayServer.Tests
btcDerivationScheme.GetDerivation(new KeyPath("0/90")).ScriptPubKey, Money.Coins(1.0m));
tester.ExplorerNode.Generate(1);
var transactions = Assert.IsType<ListTransactionsViewModel>(Assert
.IsType<ViewResult>(walletController.WalletTransactions(walletId).Result).Model);
.IsType<ViewResult>(walletController.WalletTransactions(walletId, loadTransactions: true).Result).Model);
Assert.Empty(transactions.Transactions);
Assert.IsType<RedirectToActionResult>(walletController.WalletRescan(walletId, rescan).Result);
@ -747,7 +748,7 @@ namespace BTCPayServer.Tests
Assert.NotNull(rescan.TimeOfScan);
Assert.Equal(1, rescan.LastSuccess.Found);
transactions = Assert.IsType<ListTransactionsViewModel>(Assert
.IsType<ViewResult>(walletController.WalletTransactions(walletId).Result).Model);
.IsType<ViewResult>(walletController.WalletTransactions(walletId, loadTransactions: true).Result).Model);
var tx = Assert.Single(transactions.Transactions);
Assert.Equal(tx.Id, txId.ToString());
@ -762,7 +763,7 @@ namespace BTCPayServer.Tests
await walletController.ModifyTransaction(walletId, tx.Id, addcomment: "hello"));
transactions = Assert.IsType<ListTransactionsViewModel>(Assert
.IsType<ViewResult>(walletController.WalletTransactions(walletId).Result).Model);
.IsType<ViewResult>(walletController.WalletTransactions(walletId, loadTransactions: true).Result).Model);
tx = Assert.Single(transactions.Transactions);
Assert.Equal("hello", tx.Comment);
@ -774,7 +775,7 @@ namespace BTCPayServer.Tests
await walletController.ModifyTransaction(walletId, tx.Id, removelabel: "test2"));
transactions = Assert.IsType<ListTransactionsViewModel>(Assert
.IsType<ViewResult>(walletController.WalletTransactions(walletId).Result).Model);
.IsType<ViewResult>(walletController.WalletTransactions(walletId, loadTransactions: true).Result).Model);
tx = Assert.Single(transactions.Transactions);
Assert.Equal("hello", tx.Comment);
@ -1760,7 +1761,7 @@ namespace BTCPayServer.Tests
var parsedJson = await GetExport(user);
Assert.Equal(3, parsedJson.Length);
var invoiceDueAfterFirstPayment = (3 * networkFee).ToDecimal(MoneyUnit.BTC) * invoice.Rate;
var invoiceDueAfterFirstPayment = 3 * networkFee.ToDecimal(MoneyUnit.BTC) * invoice.Rate;
var pay1str = parsedJson[0].ToString();
Assert.Contains("\"InvoiceItemDesc\": \"Some \\\", description\"", pay1str);
Assert.Equal(invoiceDueAfterFirstPayment, GetFieldValue(pay1str, "InvoiceDue"));
@ -1984,6 +1985,7 @@ namespace BTCPayServer.Tests
var user = tester.NewAccount();
user.GrantAccess(true);
user.RegisterDerivationScheme("BTC");
var btcpayClient = await user.CreateClient();
DateTimeOffset expiration = DateTimeOffset.UtcNow + TimeSpan.FromMinutes(21);
@ -2064,6 +2066,20 @@ namespace BTCPayServer.Tests
var zeroInvoicePM = await greenfield.GetInvoicePaymentMethods(user.StoreId, zeroInvoice.Id);
Assert.Empty(zeroInvoicePM);
var invoice6 = await btcpayClient.CreateInvoice(user.StoreId,
new CreateInvoiceRequest()
{
Amount = GreenfieldConstants.MaxAmount,
Currency = "USD"
});
var repo = tester.PayTester.GetService<InvoiceRepository>();
var entity = (await repo.GetInvoice(invoice6.Id));
Assert.Equal((decimal)ulong.MaxValue, entity.Price);
entity.GetPaymentMethods().First().Calculate();
// Shouldn't be possible as we clamp the value, but existing invoice may have that
entity.Price = decimal.MaxValue;
entity.GetPaymentMethods().First().Calculate();
}
[Fact(Timeout = LongRunningTestTimeout)]
@ -2920,5 +2936,124 @@ namespace BTCPayServer.Tests
Assert.IsType<ViewFilesViewModel>(Assert.IsType<ViewResult>(await controller.Files(new string[] { fileId })).Model);
Assert.Null(viewFilesViewModel.DirectUrlByFiles);
}
[Fact]
[Trait("Selenium", "Selenium")]
public async Task CanCreateReports()
{
using var tester = CreateServerTester();
tester.ActivateLightning();
tester.DeleteStore = false;
await tester.StartAsync();
await tester.EnsureChannelsSetup();
var acc = tester.NewAccount();
await acc.GrantAccessAsync();
await acc.MakeAdmin();
acc.RegisterDerivationScheme("BTC", importKeysToNBX: true);
acc.RegisterLightningNode("BTC");
await acc.ReceiveUTXO(Money.Coins(1.0m));
var client = await acc.CreateClient();
var posController = acc.GetController<UIPointOfSaleController>();
var app = await client.CreatePointOfSaleApp(acc.StoreId, new CreatePointOfSaleAppRequest()
{
AppName = "Static",
DefaultView = Client.Models.PosViewType.Static,
Template = new PointOfSaleSettings().Template
});
var resp = await posController.ViewPointOfSale(app.Id, choiceKey: "green-tea");
var invoiceId = GetInvoiceId(resp);
await acc.PayOnChain(invoiceId);
app = await client.CreatePointOfSaleApp(acc.StoreId, new CreatePointOfSaleAppRequest()
{
AppName = "Cart",
DefaultView = Client.Models.PosViewType.Cart,
Template = new PointOfSaleSettings().Template
});
resp = await posController.ViewPointOfSale(app.Id, posData: new JObject()
{
["cart"] = new JArray()
{
new JObject()
{
["id"] = "green-tea",
["count"] = 2
},
new JObject()
{
["id"] = "black-tea",
["count"] = 1
},
}
}.ToString());
invoiceId = GetInvoiceId(resp);
await acc.PayOnBOLT11(invoiceId);
resp = await posController.ViewPointOfSale(app.Id, posData: new JObject()
{
["cart"] = new JArray()
{
new JObject()
{
["id"] = "green-tea",
["count"] = 5
}
}
}.ToString());
invoiceId = GetInvoiceId(resp);
await acc.PayOnLNUrl(invoiceId);
await acc.CreateLNAddress();
await acc.PayOnLNAddress();
var report = await GetReport(acc, new() { ViewName = "Payments" });
// 1 payment on LN Address
// 1 payment on LNURL
// 1 payment on BOLT11
// 1 payment on chain
Assert.Equal(4, report.Data.Count);
var lnAddressIndex = report.GetIndex("LightningAddress");
var paymentTypeIndex = report.GetIndex("PaymentType");
Assert.Contains(report.Data, d => d[lnAddressIndex]?.Value<string>()?.Contains(acc.LNAddress) is true);
var paymentTypes = report.Data
.GroupBy(d => d[paymentTypeIndex].Value<string>())
.ToDictionary(d => d.Key);
Assert.Equal(3, paymentTypes["Lightning"].Count());
Assert.Single(paymentTypes["On-Chain"]);
// 2 on-chain transactions: It received from the cashcow, then paid its own invoice
report = await GetReport(acc, new() { ViewName = "On-Chain Wallets" });
var txIdIndex = report.GetIndex("TransactionId");
var balanceIndex = report.GetIndex("BalanceChange");
Assert.Equal(2, report.Data.Count);
Assert.Equal(64, report.Data[0][txIdIndex].Value<string>().Length);
Assert.Contains(report.Data, d => d[balanceIndex].Value<decimal>() == 1.0m);
// Items sold
report = await GetReport(acc, new() { ViewName = "Products sold" });
var itemIndex = report.GetIndex("Product");
var countIndex = report.GetIndex("Quantity");
var itemsCount = report.Data.GroupBy(d => d[itemIndex].Value<string>())
.ToDictionary(d => d.Key, r => r.Sum(d => d[countIndex].Value<int>()));
Assert.Equal(8, itemsCount["green-tea"]);
Assert.Equal(1, itemsCount["black-tea"]);
}
private async Task<StoreReportResponse> GetReport(TestAccount acc, StoreReportRequest req)
{
var controller = acc.GetController<UIReportsController>();
return (await controller.StoreReportsJson(acc.StoreId, req)).AssertType<JsonResult>()
.Value
.AssertType<StoreReportResponse>();
}
private static string GetInvoiceId(IActionResult resp)
{
var redirect = resp.AssertType<RedirectToActionResult>();
Assert.Equal("Checkout", redirect.ActionName);
return (string)redirect.RouteValues["invoiceId"];
}
}
}

@ -224,7 +224,7 @@ services:
- "5432"
merchant_lnd:
image: btcpayserver/lnd:v0.16.2-beta
image: btcpayserver/lnd:v0.16.4-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@ -259,7 +259,7 @@ services:
- bitcoind
customer_lnd:
image: btcpayserver/lnd:v0.16.2-beta
image: btcpayserver/lnd:v0.16.4-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"

@ -211,7 +211,7 @@ services:
- "5432"
merchant_lnd:
image: btcpayserver/lnd:v0.16.2-beta
image: btcpayserver/lnd:v0.16.4-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@ -248,7 +248,7 @@ services:
- bitcoind
customer_lnd:
image: btcpayserver/lnd:v0.16.2-beta
image: btcpayserver/lnd:v0.16.4-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"

@ -48,7 +48,7 @@
<PackageReference Include="YamlDotNet" Version="8.0.0" />
<PackageReference Include="BIP78.Sender" Version="0.2.2" />
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.2" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.4.26" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.4.29" />
<PackageReference Include="CsvHelper" Version="15.0.5" />
<PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Fido2" Version="2.0.2" />
@ -81,6 +81,7 @@
</ItemGroup>
<ItemGroup>
<None Include="Views\UIReports\StoreReports.cshtml" />
<None Include="wwwroot\vendor\font-awesome\fonts\fontawesome-webfont.svg" />
<None Include="wwwroot\vendor\font-awesome\fonts\fontawesome-webfont.woff2" />
<None Include="wwwroot\vendor\font-awesome\less\animated.less" />
@ -119,6 +120,7 @@
<Folder Include="wwwroot\vendor\bootstrap" />
<Folder Include="wwwroot\vendor\clipboard.js\" />
<Folder Include="wwwroot\vendor\highlightjs\" />
<Folder Include="wwwroot\vendor\pivottable\" />
<Folder Include="wwwroot\vendor\summernote" />
<Folder Include="wwwroot\vendor\tom-select" />
<Folder Include="wwwroot\vendor\ur-registry" />
@ -135,7 +137,9 @@
<ItemGroup>
<Watch Include="Views\**\*.*"></Watch>
<Content Remove="Views\UIReports\StoreReports.cshtml" />
<Watch Remove="Views\UIAccount\CheatPermissions.cshtml" />
<Watch Remove="Views\UIReports\StoreReports.cshtml" />
<Content Update="Views\UIApps\_ViewImports.cshtml">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<Pack>$(IncludeRazorContentInPack)</Pack>

@ -24,7 +24,7 @@ public class AppTopItems : ViewComponent
public async Task<IViewComponentResult> InvokeAsync(string appId, string appType)
{
var type = _appService.GetAppType(appType);
if (type is not IHasItemStatsAppType salesAppType || type is not AppBaseType appBaseType)
if (type is not (IHasItemStatsAppType and AppBaseType appBaseType))
return new HtmlContentViewComponentResult(new StringHtmlContent(string.Empty));
var vm = new AppTopItemsViewModel
@ -40,7 +40,7 @@ public class AppTopItems : ViewComponent
var app = HttpContext.GetAppData();
var entries = await _appService.GetItemStats(app);
vm.SalesCount = entries.Select(e => e.SalesCount).ToList();
vm.Entries = entries.ToList();
vm.Entries = entries.Take(5).ToList();
vm.AppType = app.AppType;
vm.AppUrl = await appBaseType.ConfigureLink(app);
vm.Name = app.Name;

@ -131,6 +131,12 @@
<span>Invoices</span>
</a>
</li>
<li class="nav-item" permission="@Policies.CanViewInvoices">
<a asp-area="" asp-controller="UIReports" asp-action="StoreReports" asp-route-storeId="@Model.Store.Id" class="nav-link @ViewData.IsActivePage(StoreNavPages.Reporting)" id="SectionNav-Reporting">
<vc:icon symbol="invoice" />
<span>Reporting</span>
</a>
</li>
<li class="nav-item" permission="@Policies.CanModifyStoreSettings">
<a asp-area="" asp-controller="UIPaymentRequest" asp-action="GetPaymentRequests" asp-route-storeId="@Model.Store.Id" class="nav-link @ViewData.IsActiveCategory(typeof(PaymentRequestsNavPages))" id="StoreNav-PaymentRequests">
<vc:icon symbol="payment-requests"/>

@ -3,6 +3,7 @@
@using BTCPayServer.Services
@using BTCPayServer.Services.Invoices
@inject DisplayFormatter DisplayFormatter
@inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary
@model BTCPayServer.Components.StoreRecentInvoices.StoreRecentInvoicesViewModel
<div class="widget store-recent-invoices" id="StoreRecentInvoices-@Model.Store.Id">
@ -51,19 +52,41 @@
<a asp-controller="UIInvoice" asp-action="Invoice" asp-route-invoiceId="@invoice.InvoiceId" class="text-break">@invoice.InvoiceId</a>
</td>
<td>
<span class="badge badge-@invoice.Status.Status.ToModernStatus().ToString().ToLower()">
@invoice.Status.Status.ToModernStatus().ToString()
@if (invoice.Status.ExceptionStatus != InvoiceExceptionStatus.None)
<div class="d-flex align-items-center gap-2">
@if (invoice.Details.Archived)
{
@($"({invoice.Status.ExceptionStatus.ToString()})")
<span class="badge bg-warning">archived</span>
}
</span>
@if (invoice.HasRefund)
{
<span class="badge bg-warning">
Refund
<span class="badge badge-@invoice.Status.Status.ToModernStatus().ToString().ToLower()">
@invoice.Status.Status.ToModernStatus().ToString()
@if (invoice.Status.ExceptionStatus != InvoiceExceptionStatus.None)
{
@($"({invoice.Status.ExceptionStatus.ToString()})")
}
</span>
}
@foreach (var paymentMethodId in invoice.Details.Payments.Select(payment => payment.GetPaymentMethodId()).Distinct())
{
var image = PaymentMethodHandlerDictionary[paymentMethodId]?.GetCryptoImage(paymentMethodId);
var badge = paymentMethodId.PaymentType.GetBadge();
if (!string.IsNullOrEmpty(image) || !string.IsNullOrEmpty(badge))
{
<span class="d-inline-flex align-items-center gap-1">
@if (!string.IsNullOrEmpty(image))
{
<img src="@Context.Request.GetRelativePathOrAbsolute(image)" alt="@paymentMethodId.PaymentType.ToString()" style="height:1.5em" />
}
@if (!string.IsNullOrEmpty(badge))
{
@badge
}
</span>
}
}
@if (invoice.HasRefund)
{
<span class="badge bg-warning">Refund</span>
}
</div>
</td>
<td class="text-end">
<span data-sensitive>@DisplayFormatter.Currency(invoice.Amount, invoice.Currency)</span>

@ -1,4 +1,5 @@
using System;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Services.Invoices;
namespace BTCPayServer.Components.StoreRecentInvoices;
@ -11,5 +12,7 @@ public class StoreRecentInvoiceViewModel
public string Currency { get; set; }
public InvoiceState Status { get; set; }
public DateTimeOffset Date { get; set; }
public InvoiceDetailsModel Details { get; set; }
public bool HasRefund { get; set; }
}

@ -3,11 +3,13 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
namespace BTCPayServer.Components.StoreRecentInvoices;
@ -53,17 +55,22 @@ public class StoreRecentInvoices : ViewComponent
});
vm.Invoices = (from invoice in invoiceEntities
let state = invoice.GetInvoiceState()
select new StoreRecentInvoiceViewModel
{
Date = invoice.InvoiceTime,
Status = state,
HasRefund = invoice.Refunds.Any(),
InvoiceId = invoice.Id,
OrderId = invoice.Metadata.OrderId ?? string.Empty,
Amount = invoice.Price,
Currency = invoice.Currency
}).ToList();
let state = invoice.GetInvoiceState()
select new StoreRecentInvoiceViewModel
{
Date = invoice.InvoiceTime,
Status = state,
HasRefund = invoice.Refunds.Any(),
InvoiceId = invoice.Id,
OrderId = invoice.Metadata.OrderId ?? string.Empty,
Amount = invoice.Price,
Currency = invoice.Currency,
Details = new InvoiceDetailsModel
{
Archived = invoice.Archived,
Payments = invoice.GetPayments(false)
}
}).ToList();
return View(vm);
}

@ -58,7 +58,7 @@ public class StoreRecentTransactions : ViewComponent
{
var network = derivationSettings.Network;
var wallet = _walletProvider.GetWallet(network);
var allTransactions = await wallet.FetchTransactionHistory(derivationSettings.AccountDerivation, 0, 5, TimeSpan.FromDays(31.0));
var allTransactions = await wallet.FetchTransactionHistory(derivationSettings.AccountDerivation, 0, 5, TimeSpan.FromDays(31.0), cancellationToken: this.HttpContext.RequestAborted);
var walletTransactionsInfo = await _walletRepository.GetWalletTransactionsInfo(vm.WalletId, allTransactions.Select(t => t.TransactionId.ToString()).ToArray());
transactions = allTransactions

@ -1,6 +1,7 @@
@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";
}
@ -15,9 +16,12 @@
}
else
{
<span class="truncate-center-truncated" @(!string.IsNullOrEmpty(Model.Start) ? $"data-bs-toggle=tooltip title={Model.Text}" : "")>
<span class="truncate-center-start">@(Model.Elastic ? Model.Text : $"{Model.Start}…")</span>
<span class="truncate-center-end">@Model.End</span>
<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>
<span class="truncate-center-text">@Model.Text</span>
}

@ -1,17 +1,23 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.ModelBinders;
using BTCPayServer.Models;
using BTCPayServer.Security;
using BTCPayServer.Payments;
using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NBitpayClient;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers
{
@ -36,7 +42,7 @@ namespace BTCPayServer.Controllers
{
if (invoice == null)
throw new BitpayHttpException(400, "Invalid invoice");
return await _InvoiceController.CreateInvoiceCore(invoice, HttpContext.GetStoreData(), HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
return await CreateInvoiceCore(invoice, HttpContext.GetStoreData(), HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
}
[HttpGet]
@ -66,7 +72,7 @@ namespace BTCPayServer.Controllers
int? limit = null,
int? offset = null)
{
if (User.Identity.AuthenticationType == Security.Bitpay.BitpayAuthenticationTypes.Anonymous)
if (User.Identity?.AuthenticationType == Security.Bitpay.BitpayAuthenticationTypes.Anonymous)
return Forbid(Security.Bitpay.BitpayAuthenticationTypes.Anonymous);
if (dateEnd != null)
dateEnd = dateEnd.Value + TimeSpan.FromDays(1); //Should include the end day
@ -88,5 +94,133 @@ namespace BTCPayServer.Controllers
return Json(DataWrapper.Create(entities));
}
internal async Task<DataWrapper<InvoiceResponse>> CreateInvoiceCore(BitpayCreateInvoiceRequest invoice,
StoreData store, string serverUrl, List<string> additionalTags = null,
CancellationToken cancellationToken = default, Action<InvoiceEntity> entityManipulator = null)
{
var entity = await CreateInvoiceCoreRaw(invoice, store, serverUrl, additionalTags, cancellationToken, entityManipulator);
var resp = entity.EntityToDTO();
return new DataWrapper<InvoiceResponse>(resp) { Facade = "pos/invoice" };
}
internal async Task<InvoiceEntity> CreateInvoiceCoreRaw(BitpayCreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string> additionalTags = null, CancellationToken cancellationToken = default, Action<InvoiceEntity> entityManipulator = null)
{
var storeBlob = store.GetStoreBlob();
var entity = _InvoiceRepository.CreateNewInvoice(store.Id);
entity.ExpirationTime = invoice.ExpirationTime is { } v ? v : entity.InvoiceTime + storeBlob.InvoiceExpiration;
entity.MonitoringExpiration = entity.ExpirationTime + storeBlob.MonitoringExpiration;
if (entity.ExpirationTime - TimeSpan.FromSeconds(30.0) < entity.InvoiceTime)
{
throw new BitpayHttpException(400, "The expirationTime is set too soon");
}
if (entity.Price < 0.0m)
{
throw new BitpayHttpException(400, "The price should be 0 or more.");
}
if (entity.Price > GreenfieldConstants.MaxAmount)
{
throw new BitpayHttpException(400, $"The price should less than {GreenfieldConstants.MaxAmount}.");
}
entity.Metadata.OrderId = invoice.OrderId;
entity.Metadata.PosDataLegacy = invoice.PosData;
entity.ServerUrl = serverUrl;
entity.FullNotifications = invoice.FullNotifications || invoice.ExtendedNotifications;
entity.ExtendedNotifications = invoice.ExtendedNotifications;
entity.NotificationURLTemplate = invoice.NotificationURL;
entity.NotificationEmail = invoice.NotificationEmail;
if (additionalTags != null)
entity.InternalTags.AddRange(additionalTags);
FillBuyerInfo(invoice, entity);
var price = invoice.Price;
entity.Metadata.ItemCode = invoice.ItemCode;
entity.Metadata.ItemDesc = invoice.ItemDesc;
entity.Metadata.Physical = invoice.Physical;
entity.Metadata.TaxIncluded = invoice.TaxIncluded;
entity.Currency = invoice.Currency;
if (price is { } vv)
{
entity.Price = vv;
entity.Type = InvoiceType.Standard;
}
else
{
entity.Price = 0m;
entity.Type = InvoiceType.TopUp;
}
entity.StoreSupportUrl = storeBlob.StoreSupportUrl;
entity.RedirectURLTemplate = invoice.RedirectURL ?? store.StoreWebsite;
entity.RedirectAutomatically =
invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically);
entity.RequiresRefundEmail = invoice.RequiresRefundEmail;
entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy);
IPaymentFilter excludeFilter = null;
if (invoice.PaymentCurrencies?.Any() is true)
{
invoice.SupportedTransactionCurrencies ??=
new Dictionary<string, InvoiceSupportedTransactionCurrency>();
foreach (string paymentCurrency in invoice.PaymentCurrencies)
{
invoice.SupportedTransactionCurrencies.TryAdd(paymentCurrency,
new InvoiceSupportedTransactionCurrency() { Enabled = true });
}
}
if (invoice.SupportedTransactionCurrencies != null && invoice.SupportedTransactionCurrencies.Count != 0)
{
var supportedTransactionCurrencies = invoice.SupportedTransactionCurrencies
.Where(c => c.Value.Enabled)
.Select(c => PaymentMethodId.TryParse(c.Key, out var p) ? p : null)
.Where(c => c != null)
.ToHashSet();
excludeFilter = PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p));
}
entity.PaymentTolerance = storeBlob.PaymentTolerance;
entity.DefaultPaymentMethod = invoice.DefaultPaymentMethod;
entity.RequiresRefundEmail = invoice.RequiresRefundEmail;
return await _InvoiceController.CreateInvoiceCoreRaw(entity, store, excludeFilter, null, cancellationToken, entityManipulator);
}
private void FillBuyerInfo(BitpayCreateInvoiceRequest req, InvoiceEntity invoiceEntity)
{
var buyerInformation = invoiceEntity.Metadata;
buyerInformation.BuyerAddress1 = req.BuyerAddress1;
buyerInformation.BuyerAddress2 = req.BuyerAddress2;
buyerInformation.BuyerCity = req.BuyerCity;
buyerInformation.BuyerCountry = req.BuyerCountry;
buyerInformation.BuyerEmail = req.BuyerEmail;
buyerInformation.BuyerName = req.BuyerName;
buyerInformation.BuyerPhone = req.BuyerPhone;
buyerInformation.BuyerState = req.BuyerState;
buyerInformation.BuyerZip = req.BuyerZip;
var buyer = req.Buyer;
if (buyer == null)
return;
buyerInformation.BuyerAddress1 ??= buyer.Address1;
buyerInformation.BuyerAddress2 ??= buyer.Address2;
buyerInformation.BuyerCity ??= buyer.City;
buyerInformation.BuyerCountry ??= buyer.country;
buyerInformation.BuyerEmail ??= buyer.email;
buyerInformation.BuyerName ??= buyer.Name;
buyerInformation.BuyerPhone ??= buyer.phone;
buyerInformation.BuyerState ??= buyer.State;
buyerInformation.BuyerZip ??= buyer.zip;
}
private SpeedPolicy ParseSpeedPolicy(string transactionSpeed, SpeedPolicy defaultPolicy)
{
if (transactionSpeed == null)
return defaultPolicy;
var mappings = new Dictionary<string, SpeedPolicy>();
mappings.Add("low", SpeedPolicy.LowSpeed);
mappings.Add("low-medium", SpeedPolicy.LowMediumSpeed);
mappings.Add("medium", SpeedPolicy.MediumSpeed);
mappings.Add("high", SpeedPolicy.HighSpeed);
if (!mappings.TryGetValue(transactionSpeed, out SpeedPolicy policy))
policy = defaultPolicy;
return policy;
}
}
}

@ -12,6 +12,7 @@ using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
using BTCPayServer.Rating;
using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
@ -183,6 +184,10 @@ namespace BTCPayServer.Controllers.Greenfield
{
ModelState.AddModelError(nameof(request.Amount), "The amount should be 0 or more.");
}
if (request.Amount > GreenfieldConstants.MaxAmount)
{
ModelState.AddModelError(nameof(request.Amount), $"The amount should less than {GreenfieldConstants.MaxAmount}.");
}
request.Checkout ??= new CreateInvoiceRequest.CheckoutOptions();
if (request.Checkout.PaymentMethods?.Any() is true)
{
@ -391,7 +396,7 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateValidationError(ModelState);
var accounting = invoicePaymentMethod.Calculate();
var cryptoPaid = accounting.Paid.ToDecimal(MoneyUnit.BTC);
var cryptoPaid = accounting.Paid;
var cdCurrency = _currencyNameTable.GetCurrencyData(invoice.Currency, true);
var paidCurrency = Math.Round(cryptoPaid * invoicePaymentMethod.Rate, cdCurrency.Divisibility);
var rateResult = await _rateProvider.FetchRate(
@ -459,7 +464,7 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateValidationError(ModelState);
}
var dueAmount = accounting.TotalDue.ToDecimal(MoneyUnit.BTC);
var dueAmount = accounting.TotalDue;
createPullPayment.Currency = cryptoCode;
createPullPayment.Amount = Math.Round(paidAmount - dueAmount, appliedDivisibility);
createPullPayment.AutoApproveClaims = true;
@ -575,11 +580,11 @@ namespace BTCPayServer.Controllers.Greenfield
CryptoCode = method.GetId().CryptoCode,
Destination = details.GetPaymentDestination(),
Rate = method.Rate,
Due = accounting.DueUncapped.ToDecimal(MoneyUnit.BTC),
TotalPaid = accounting.Paid.ToDecimal(MoneyUnit.BTC),
PaymentMethodPaid = accounting.CryptoPaid.ToDecimal(MoneyUnit.BTC),
Amount = accounting.TotalDue.ToDecimal(MoneyUnit.BTC),
NetworkFee = accounting.NetworkFee.ToDecimal(MoneyUnit.BTC),
Due = accounting.DueUncapped,
TotalPaid = accounting.Paid,
PaymentMethodPaid = accounting.CryptoPaid,
Amount = accounting.TotalDue,
NetworkFee = accounting.NetworkFee,
PaymentLink =
method.GetId().PaymentType.GetPaymentLink(method.Network, entity, details, accounting.Due,
Request.GetAbsoluteRoot()),

@ -255,7 +255,9 @@ namespace BTCPayServer.Controllers.Greenfield
Email = blob.Email,
AllowCustomPaymentAmounts = blob.AllowCustomPaymentAmounts,
EmbeddedCSS = blob.EmbeddedCSS,
CustomCSSLink = blob.CustomCSSLink
CustomCSSLink = blob.CustomCSSLink,
FormResponse = blob.FormResponse,
FormId = blob.FormId
};
}

@ -19,6 +19,7 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json.Linq;
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
namespace BTCPayServer.Controllers.Greenfield
@ -255,16 +256,15 @@ namespace BTCPayServer.Controllers.Greenfield
return PullPaymentNotFound();
var blob = pp.GetBlob();
var pms = blob.SupportedPaymentMethods.FirstOrDefault(id => id.PaymentType == LightningPaymentType.Instance && _networkProvider.DefaultNetwork.CryptoCode == id.CryptoCode);
if (pms is not null && blob.Currency.Equals(pms.CryptoCode, StringComparison.InvariantCultureIgnoreCase))
if (_pullPaymentService.SupportsLNURL(blob))
{
var lnurlEndpoint = new Uri(Url.Action("GetLNURLForPullPayment", "UILNURL", new
{
cryptoCode = _networkProvider.DefaultNetwork.CryptoCode,
pullPaymentId = pullPaymentId
pullPaymentId
}, Request.Scheme, Request.Host.ToString())!);
return base.Ok(new PullPaymentLNURL()
return base.Ok(new PullPaymentLNURL
{
LNURLBech32 = LNURL.LNURL.EncodeUri(lnurlEndpoint, "withdrawRequest", true).ToString(),
LNURLUri = LNURL.LNURL.EncodeUri(lnurlEndpoint, "withdrawRequest", false).ToString()
@ -285,7 +285,8 @@ namespace BTCPayServer.Controllers.Greenfield
Amount = blob.Amount,
PaymentMethodAmount = blob.CryptoAmount,
Revision = blob.Revision,
State = p.State
State = p.State,
Metadata = blob.Metadata?? new JObject(),
};
model.Destination = blob.Destination;
model.PaymentMethod = p.PaymentMethodId;
@ -322,27 +323,20 @@ namespace BTCPayServer.Controllers.Greenfield
ModelState.AddModelError(nameof(request.Destination), destination.error ?? "The destination is invalid for the payment specified");
return this.CreateValidationError(ModelState);
}
if (request.Amount is null && destination.destination.Amount != null)
var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, request.Amount, paymentMethodId.CryptoCode, ppBlob.Currency);
if (amtError.error is not null)
{
request.Amount = destination.destination.Amount;
}
else if (request.Amount != null && destination.destination.Amount != null && request.Amount != destination.destination.Amount)
{
ModelState.AddModelError(nameof(request.Amount), $"Amount is implied in destination ({destination.destination.Amount}) that does not match the payout amount provided {request.Amount})");
return this.CreateValidationError(ModelState);
}
if (request.Amount is { } v && (v < ppBlob.MinimumClaim || v == 0.0m))
{
ModelState.AddModelError(nameof(request.Amount), $"Amount too small (should be at least {ppBlob.MinimumClaim})");
ModelState.AddModelError(nameof(request.Amount), amtError.error );
return this.CreateValidationError(ModelState);
}
request.Amount = amtError.amount;
var result = await _pullPaymentService.Claim(new ClaimRequest()
{
Destination = destination.destination,
PullPaymentId = pullPaymentId,
Value = request.Amount,
PaymentMethodId = paymentMethodId,
PaymentMethodId = paymentMethodId
});
return HandleClaimResult(result);
@ -394,15 +388,13 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateValidationError(ModelState);
}
if (request.Amount is null && destination.destination.Amount != null)
var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, request.Amount);
if (amtError.error is not null)
{
request.Amount = destination.destination.Amount;
}
else if (request.Amount != null && destination.destination.Amount != null && request.Amount != destination.destination.Amount)
{
ModelState.AddModelError(nameof(request.Amount), $"Amount is implied in destination ({destination.destination.Amount}) that does not match the payout amount provided {request.Amount})");
ModelState.AddModelError(nameof(request.Amount), amtError.error );
return this.CreateValidationError(ModelState);
}
request.Amount = amtError.amount;
if (request.Amount is { } v && (v < ppBlob?.MinimumClaim || v == 0.0m))
{
var minimumClaim = ppBlob?.MinimumClaim is decimal val ? val : 0.0m;
@ -416,7 +408,8 @@ namespace BTCPayServer.Controllers.Greenfield
PreApprove = request.Approved,
Value = request.Amount,
PaymentMethodId = paymentMethodId,
StoreId = storeId
StoreId = storeId,
Metadata = request.Metadata
});
return HandleClaimResult(result);
}

@ -0,0 +1,80 @@
#nullable enable
using BTCPayServer.Lightning;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Services.Invoices;
using System.Collections.Generic;
using System.Threading.Tasks;
using System;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore.Internal;
using NBitcoin;
using Newtonsoft.Json.Linq;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Dapper;
using Microsoft.AspNetCore.Authorization;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Services;
using System.Linq;
using System.Threading;
namespace BTCPayServer.Controllers.GreenField;
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldReportsController : Controller
{
public GreenfieldReportsController(
ApplicationDbContextFactory dbContextFactory,
ReportService reportService)
{
DBContextFactory = dbContextFactory;
ReportService = reportService;
}
public ApplicationDbContextFactory DBContextFactory { get; }
public ReportService ReportService { get; }
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/reports")]
[NonAction] // Disabling this endpoint as we still need to figure out the request/response model
public async Task<IActionResult> StoreReports(string storeId, [FromBody] StoreReportRequest? vm = null, CancellationToken cancellationToken = default)
{
vm ??= new StoreReportRequest();
vm.ViewName ??= "Payments";
vm.TimePeriod ??= new TimePeriod();
vm.TimePeriod.To ??= DateTime.UtcNow;
vm.TimePeriod.From ??= vm.TimePeriod.To.Value.AddMonths(-1);
var from = vm.TimePeriod.From.Value;
var to = vm.TimePeriod.To.Value;
if (ReportService.ReportProviders.TryGetValue(vm.ViewName, out var report))
{
if (!report.IsAvailable())
return this.CreateAPIError(503, "view-unavailable", $"This view is unavailable at this moment");
var ctx = new Services.Reporting.QueryContext(storeId, from, to);
await report.Query(ctx, cancellationToken);
var result = new StoreReportResponse()
{
Fields = ctx.ViewDefinition?.Fields ?? new List<StoreReportResponse.Field>(),
Charts = ctx.ViewDefinition?.Charts ?? new List<ChartDefinition>(),
Data = ctx.Data.Select(d => new JArray(d)).ToList(),
From = from,
To = to
};
return Json(result);
}
else
{
ModelState.AddModelError(nameof(vm.ViewName), "View doesn't exist");
return this.CreateValidationError(ModelState);
}
}
}

@ -53,16 +53,23 @@ namespace BTCPayServer.Controllers.Greenfield
private static LightningAutomatedPayoutSettings ToModel(PayoutProcessorData data)
{
var blob = data.HasTypedBlob<LightningAutomatedPayoutBlob>().GetBlob();
return new LightningAutomatedPayoutSettings()
{
PaymentMethod = data.PaymentMethod,
IntervalSeconds = data.HasTypedBlob<AutomatedPayoutBlob>().GetBlob()!.Interval
IntervalSeconds = blob.Interval,
CancelPayoutAfterFailures = blob.CancelPayoutAfterFailures,
ProcessNewPayoutsInstantly = blob.ProcessNewPayoutsInstantly
};
}
private static AutomatedPayoutBlob FromModel(LightningAutomatedPayoutSettings data)
private static LightningAutomatedPayoutBlob FromModel(LightningAutomatedPayoutSettings data)
{
return new AutomatedPayoutBlob() { Interval = data.IntervalSeconds };
return new LightningAutomatedPayoutBlob() {
Interval = data.IntervalSeconds,
CancelPayoutAfterFailures = data.CancelPayoutAfterFailures,
ProcessNewPayoutsInstantly = data.ProcessNewPayoutsInstantly
};
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
@ -84,7 +91,7 @@ namespace BTCPayServer.Controllers.Greenfield
}))
.FirstOrDefault();
activeProcessor ??= new PayoutProcessorData();
activeProcessor.HasTypedBlob<AutomatedPayoutBlob>().SetBlob(FromModel(request));
activeProcessor.HasTypedBlob<LightningAutomatedPayoutBlob>().SetBlob(FromModel(request));
activeProcessor.StoreId = storeId;
activeProcessor.PaymentMethod = paymentMethod;
activeProcessor.Processor = LightningAutomatedPayoutSenderFactory.ProcessorName;

@ -59,7 +59,9 @@ namespace BTCPayServer.Controllers.Greenfield
{
FeeBlockTarget = blob.FeeTargetBlock,
PaymentMethod = data.PaymentMethod,
IntervalSeconds = blob.Interval
IntervalSeconds = blob.Interval,
Threshold = blob.Threshold,
ProcessNewPayoutsInstantly = blob.ProcessNewPayoutsInstantly
};
}
@ -68,7 +70,9 @@ namespace BTCPayServer.Controllers.Greenfield
return new OnChainAutomatedPayoutBlob()
{
FeeTargetBlock = data.FeeBlockTarget ?? 1,
Interval = data.IntervalSeconds
Interval = data.IntervalSeconds,
Threshold = data.Threshold,
ProcessNewPayoutsInstantly = data.ProcessNewPayoutsInstantly
};
}

@ -182,7 +182,8 @@ namespace BTCPayServer.Controllers.Greenfield
[FromQuery] TransactionStatus[]? statusFilter = null,
[FromQuery] string? labelFilter = null,
[FromQuery] int skip = 0,
[FromQuery] int limit = int.MaxValue
[FromQuery] int limit = int.MaxValue,
CancellationToken cancellationToken = default
)
{
if (IsInvalidWalletRequest(cryptoCode, out var network,
@ -197,7 +198,7 @@ namespace BTCPayServer.Controllers.Greenfield
if (statusFilter?.Any() is true || !string.IsNullOrWhiteSpace(labelFilter))
preFiltering = false;
var txs = await wallet.FetchTransactionHistory(derivationScheme.AccountDerivation, preFiltering ? skip : 0,
preFiltering ? limit : int.MaxValue);
preFiltering ? limit : int.MaxValue, cancellationToken: cancellationToken);
if (!preFiltering)
{
var filteredList = new List<TransactionHistoryLine>(txs.Count);

@ -129,6 +129,7 @@ namespace BTCPayServer.Controllers.Greenfield
//we do not include EmailSettings in this model and instead opt to set it in stores/storeid/email endpoints
//we do not include PaymentMethodCriteria because moving the CurrencyValueJsonConverter to the Client csproj is hard and requires a refactor (#1571 & #1572)
NetworkFeeMode = storeBlob.NetworkFeeMode,
DefaultCurrency = storeBlob.DefaultCurrency,
RequiresRefundEmail = storeBlob.RequiresRefundEmail,
CheckoutType = storeBlob.CheckoutType,
Receipt = InvoiceDataBase.ReceiptOptions.Merge(storeBlob.ReceiptOptions, null),

@ -1,6 +1,9 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Data;
@ -8,6 +11,7 @@ using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
@ -21,17 +25,20 @@ namespace BTCPayServer.Controllers
public UIAppsController(
UserManager<ApplicationUser> userManager,
StoreRepository storeRepository,
IFileService fileService,
AppService appService,
IHtmlHelper html)
{
_userManager = userManager;
_storeRepository = storeRepository;
_fileService = fileService;
_appService = appService;
Html = html;
}
private readonly UserManager<ApplicationUser> _userManager;
private readonly StoreRepository _storeRepository;
private readonly IFileService _fileService;
private readonly AppService _appService;
public string CreatedAppId { get; set; }
@ -184,13 +191,50 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(UIStoresController.Dashboard), "UIStores", new { storeId = app.StoreDataId });
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpPost("{appId}/upload-file")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> FileUpload(IFormFile file)
{
var app = GetCurrentApp();
var userId = GetUserId();
if (app is null || userId is null)
return NotFound();
if (!file.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
{
return Json(new { error = "The file needs to be an image" });
}
if (file.Length > 500_000)
{
return Json(new { error = "The image file size should be less than 0.5MB" });
}
var formFile = await file.Bufferize();
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
{
return Json(new { error = "The file needs to be an image" });
}
try
{
var storedFile = await _fileService.AddFile(file, userId);
var fileId = storedFile.Id;
var fileUrl = await _fileService.GetFileUrl(Request.GetAbsoluteRootUri(), fileId);
return Json(new { fileId, fileUrl });
}
catch (Exception e)
{
return Json(new { error = $"Could not save file: {e.Message}" });
}
}
async Task<string> GetStoreDefaultCurrentIfEmpty(string storeId, string currency)
{
if (string.IsNullOrWhiteSpace(currency))
{
currency = (await _storeRepository.FindStore(storeId)).GetStoreBlob().DefaultCurrency;
var store = await _storeRepository.FindStore(storeId);
currency = store?.GetStoreBlob().DefaultCurrency;
}
return currency.Trim().ToUpperInvariant();
return currency?.Trim().ToUpperInvariant();
}
private string GetUserId() => _userManager.GetUserId(User);

@ -55,7 +55,7 @@ namespace BTCPayServer.Controllers
return Ok(new
{
Txid = txid,
AmountRemaining = (paymentMethod.Calculate().Due - amount).ToUnit(MoneyUnit.BTC),
AmountRemaining = paymentMethod.Calculate().Due - amount.ToDecimal(MoneyUnit.BTC),
SuccessMessage = $"Created transaction {txid}"
});
@ -70,11 +70,11 @@ namespace BTCPayServer.Controllers
{
var bolt11 = BOLT11PaymentRequest.Parse(destination, network);
var paymentHash = bolt11.PaymentHash?.ToString();
var paid = new Money(response.Details.TotalAmount.ToUnit(LightMoneyUnit.Satoshi), MoneyUnit.Satoshi);
var paid = response.Details.TotalAmount.ToDecimal(LightMoneyUnit.BTC);
return Ok(new
{
Txid = paymentHash,
AmountRemaining = (paymentMethod.Calculate().TotalDue - paid).ToUnit(MoneyUnit.BTC),
AmountRemaining = paymentMethod.Calculate().TotalDue - paid,
SuccessMessage = $"Sent payment {paymentHash}"
});
}

@ -16,11 +16,10 @@ using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.HostedServices;
using BTCPayServer.Models;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Rating;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
@ -172,7 +171,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("i/{invoiceId}/receipt")]
public async Task<IActionResult> InvoiceReceipt(string invoiceId)
public async Task<IActionResult> InvoiceReceipt(string invoiceId, [FromQuery] bool print = false)
{
var i = await _InvoiceRepository.GetInvoice(invoiceId);
if (i is null)
@ -228,18 +227,14 @@ namespace BTCPayServer.Controllers
string txId = paymentData.GetPaymentId();
string? link = GetTransactionLink(paymentMethodId, txId);
var paymentMethod = i.GetPaymentMethod(paymentMethodId);
var amount = paymentData.GetValue();
var rate = paymentMethod.Rate;
var paid = (amount - paymentEntity.NetworkFee) * rate;
return new ViewPaymentRequestViewModel.PaymentRequestInvoicePayment
{
Amount = amount,
Paid = paid,
Amount = paymentEntity.PaidAmount.Gross,
Paid = paymentEntity.PaidAmount.Net,
ReceivedDate = paymentEntity.ReceivedTime.DateTime,
PaidFormatted = _displayFormatter.Currency(paid, i.Currency, DisplayFormatter.CurrencyFormat.Symbol),
RateFormatted = _displayFormatter.Currency(rate, i.Currency, DisplayFormatter.CurrencyFormat.Symbol),
PaidFormatted = _displayFormatter.Currency(paymentEntity.PaidAmount.Net, i.Currency, DisplayFormatter.CurrencyFormat.Symbol),
RateFormatted = _displayFormatter.Currency(paymentEntity.Rate, i.Currency, DisplayFormatter.CurrencyFormat.Symbol),
PaymentMethod = paymentMethodId.ToPrettyString(),
Link = link,
Id = txId,
@ -255,7 +250,7 @@ namespace BTCPayServer.Controllers
vm.Payments = receipt.ShowPayments is false ? null : payments;
vm.AdditionalData = PosDataParser.ParsePosData(receiptData);
return View(vm);
return View(print ? "InvoiceReceiptPrint" : "InvoiceReceipt", vm);
}
private string? GetTransactionLink(PaymentMethodId paymentMethodId, string txId)
@ -364,8 +359,8 @@ namespace BTCPayServer.Controllers
if (paymentMethod != null)
{
accounting = paymentMethod.Calculate();
cryptoPaid = accounting.Paid.ToDecimal(MoneyUnit.BTC);
dueAmount = accounting.TotalDue.ToDecimal(MoneyUnit.BTC);
cryptoPaid = accounting.Paid;
dueAmount = accounting.TotalDue;
paidAmount = cryptoPaid.RoundToSignificant(appliedDivisibility);
}
@ -456,7 +451,7 @@ namespace BTCPayServer.Controllers
model.Title = "How much to refund?";
model.RefundStep = RefundSteps.SelectRate;
if (isPaidOver)
if (!isPaidOver)
{
ModelState.AddModelError(nameof(model.SelectedRefundOption), "Invoice is not overpaid");
}
@ -466,7 +461,7 @@ namespace BTCPayServer.Controllers
}
if (!ModelState.IsValid)
{
return this.CreateValidationError(ModelState);
return View("_RefundModal", model);
}
createPullPayment.Currency = paymentMethodId.CryptoCode;
@ -560,7 +555,7 @@ namespace BTCPayServer.Controllers
{
var accounting = data.Calculate();
var paymentMethodId = data.GetId();
var overpaidAmount = accounting.OverpaidHelper.ToDecimal(MoneyUnit.BTC);
var overpaidAmount = accounting.OverpaidHelper;
if (overpaidAmount > 0)
{
@ -571,8 +566,8 @@ namespace BTCPayServer.Controllers
{
PaymentMethodId = paymentMethodId,
PaymentMethod = paymentMethodId.ToPrettyString(),
Due = _displayFormatter.Currency(accounting.Due.ToDecimal(MoneyUnit.BTC), paymentMethodId.CryptoCode),
Paid = _displayFormatter.Currency(accounting.CryptoPaid.ToDecimal(MoneyUnit.BTC), paymentMethodId.CryptoCode),
Due = _displayFormatter.Currency(accounting.Due, paymentMethodId.CryptoCode),
Paid = _displayFormatter.Currency(accounting.CryptoPaid, paymentMethodId.CryptoCode),
Overpaid = _displayFormatter.Currency(overpaidAmount, paymentMethodId.CryptoCode),
Address = data.GetPaymentMethodDetails().GetPaymentDestination(),
Rate = ExchangeRate(data.GetId().CryptoCode, data),
@ -827,7 +822,6 @@ namespace BTCPayServer.Controllers
var dto = invoice.EntityToDTO();
var accounting = paymentMethod.Calculate();
var paymentMethodHandler = _paymentMethodHandlerDictionary[paymentMethodId];
var divisibility = _CurrencyNameTable.GetNumberFormatInfo(paymentMethod.GetId().CryptoCode, false)?.CurrencyDecimalDigits;
switch (lang?.ToLowerInvariant())
{
@ -885,10 +879,10 @@ namespace BTCPayServer.Controllers
OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback,
CryptoImage = Request.GetRelativePathOrAbsolute(paymentMethodHandler.GetCryptoImage(paymentMethodId)),
BtcAddress = paymentMethodDetails.GetPaymentDestination(),
BtcDue = accounting.Due.ShowMoney(divisibility),
BtcPaid = accounting.Paid.ShowMoney(divisibility),
BtcDue = accounting.ShowMoney(accounting.Due),
BtcPaid = accounting.ShowMoney(accounting.Paid),
InvoiceCurrency = invoice.Currency,
OrderAmount = (accounting.TotalDue - accounting.NetworkFee).ShowMoney(divisibility),
OrderAmount = accounting.ShowMoney(accounting.TotalDue - accounting.NetworkFee),
IsUnsetTopUp = invoice.IsUnsetTopUp(),
CustomerEmail = invoice.RefundMail,
RequiresRefundEmail = invoice.RequiresRefundEmail ?? storeBlob.RequiresRefundEmail,
@ -960,6 +954,16 @@ namespace BTCPayServer.Controllers
model.PaymentMethodId = paymentMethodId.ToString();
model.PaymentType = paymentMethodId.PaymentType.ToString();
model.OrderAmountFiat = OrderAmountFromInvoice(model.CryptoCode, invoice, DisplayFormatter.CurrencyFormat.Symbol);
if (storeBlob.PlaySoundOnPayment)
{
model.PaymentSoundUrl = string.IsNullOrEmpty(storeBlob.SoundFileId)
? string.Concat(Request.GetAbsoluteRootUri().ToString(), "checkout-v2/payment.mp3")
: await _fileService.GetFileUrl(Request.GetAbsoluteRootUri(), storeBlob.SoundFileId);
model.ErrorSoundUrl = string.Concat(Request.GetAbsoluteRootUri().ToString(), "checkout-v2/error.mp3");
model.NfcReadSoundUrl = string.Concat(Request.GetAbsoluteRootUri().ToString(), "checkout-v2/nfcread.mp3");
}
var expiration = TimeSpan.FromSeconds(model.ExpirationSeconds);
model.TimeLeft = expiration.PrettyPrint();
return model;
@ -1089,22 +1093,22 @@ namespace BTCPayServer.Controllers
}
model.Search = fs;
model.SearchText = fs.TextSearch;
InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, timezoneOffset);
var apps = await _appService.GetAllApps(GetUserId(), false, storeId);
InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, apps, timezoneOffset);
invoiceQuery.StoreId = storeIds.ToArray();
invoiceQuery.Take = model.Count;
invoiceQuery.Skip = model.Skip;
invoiceQuery.IncludeRefunds = true;
var list = await _InvoiceRepository.GetInvoices(invoiceQuery);
// Apps
var apps = await _appService.GetAllApps(GetUserId(), false, storeId);
model.Apps = apps.Select(a => new InvoiceAppModel
{
Id = a.Id,
AppName = a.AppName,
AppType = a.AppType,
AppOrderId = AppService.GetAppOrderId(a.AppType, a.Id)
AppType = a.AppType
}).ToList();
foreach (var invoice in list)
@ -1129,11 +1133,21 @@ namespace BTCPayServer.Controllers
return View(model);
}
private InvoiceQuery GetInvoiceQuery(SearchString fs, int timezoneOffset = 0)
private InvoiceQuery GetInvoiceQuery(SearchString fs, ListAppsViewModel.ListAppViewModel[] apps, int timezoneOffset = 0)
{
var textSearch = fs.TextSearch;
if (fs.GetFilterArray("appid") is { } appIds)
{
var appsById = apps.ToDictionary(a => a.Id);
var searchTexts = appIds.Select(a => appsById.TryGet(a)).Where(a => a != null)
.Select(a => AppService.GetAppSearchTerm(a.AppType, a.Id))
.ToList();
searchTexts.Add(fs.TextSearch);
textSearch = string.Join(' ', searchTexts.Where(t => !string.IsNullOrEmpty(t)).ToList());
}
return new InvoiceQuery
{
TextSearch = fs.TextSearch,
TextSearch = textSearch,
UserId = GetUserId(),
Unusual = fs.GetFilterBool("unusual"),
IncludeArchived = fs.GetFilterBool("includearchived") ?? false,
@ -1165,7 +1179,8 @@ namespace BTCPayServer.Controllers
storeIds.Add(i);
}
InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, timezoneOffset);
var apps = await _appService.GetAllApps(GetUserId(), false, storeId);
InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, apps, timezoneOffset);
invoiceQuery.StoreId = storeIds.ToArray();
invoiceQuery.Skip = 0;
invoiceQuery.Take = int.MaxValue;
@ -1273,32 +1288,40 @@ namespace BTCPayServer.Controllers
try
{
var result = await CreateInvoiceCore(new BitpayCreateInvoiceRequest
var result = await CreateInvoiceCoreRaw(new CreateInvoiceRequest()
{
Price = model.Amount,
Amount = model.Amount,
Currency = model.Currency,
PosData = model.PosData,
OrderId = model.OrderId,
NotificationURL = model.NotificationUrl,
ItemDesc = model.ItemDesc,
FullNotifications = true,
BuyerEmail = model.BuyerEmail,
SupportedTransactionCurrencies = model.SupportedTransactionCurrencies?.ToDictionary(s => s, s => new InvoiceSupportedTransactionCurrency
Metadata = new InvoiceMetadata()
{
Enabled = true
}),
DefaultPaymentMethod = model.DefaultPaymentMethod,
NotificationEmail = model.NotificationEmail,
ExtendedNotifications = model.NotificationEmail != null,
RequiresRefundEmail = model.RequiresRefundEmail == RequiresRefundEmail.InheritFromStore
? storeBlob.RequiresRefundEmail
: model.RequiresRefundEmail == RequiresRefundEmail.On,
}, store, HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
PosDataLegacy = model.PosData,
OrderId = model.OrderId,
ItemDesc = model.ItemDesc,
BuyerEmail = model.BuyerEmail,
}.ToJObject(),
Checkout = new ()
{
RedirectURL = store.StoreWebsite,
DefaultPaymentMethod = model.DefaultPaymentMethod,
RequiresRefundEmail = model.RequiresRefundEmail == RequiresRefundEmail.InheritFromStore
? storeBlob.RequiresRefundEmail
: model.RequiresRefundEmail == RequiresRefundEmail.On,
PaymentMethods = model.SupportedTransactionCurrencies?.ToArray()
},
}, store, HttpContext.Request.GetAbsoluteRoot(),
entityManipulator: (entity) =>
{
entity.NotificationURLTemplate = model.NotificationUrl;
entity.FullNotifications = true;
entity.NotificationEmail = model.NotificationEmail;
entity.ExtendedNotifications = model.NotificationEmail != null;
},
cancellationToken: cancellationToken);
TempData[WellKnownTempData.SuccessMessage] = $"Invoice {result.Data.Id} just created!";
CreatedInvoiceId = result.Data.Id;
TempData[WellKnownTempData.SuccessMessage] = $"Invoice {result.Id} just created!";
CreatedInvoiceId = result.Id;
return RedirectToAction(nameof(Invoice), new { storeId = result.Data.StoreId, invoiceId = result.Data.Id });
return RedirectToAction(nameof(Invoice), new { storeId = result.StoreId, invoiceId = result.Id });
}
catch (BitpayHttpException ex)
{

@ -5,34 +5,31 @@ using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Models;
using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Rating;
using BTCPayServer.Security;
using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.PaymentRequests;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using NBitcoin;
using NBitpayClient;
using Newtonsoft.Json.Linq;
using BitpayCreateInvoiceRequest = BTCPayServer.Models.BitpayCreateInvoiceRequest;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers
@ -59,6 +56,7 @@ namespace BTCPayServer.Controllers
private readonly LinkGenerator _linkGenerator;
private readonly IAuthorizationService _authorizationService;
private readonly AppService _appService;
private readonly IFileService _fileService;
public WebhookSender WebhookNotificationManager { get; }
@ -83,6 +81,7 @@ namespace BTCPayServer.Controllers
InvoiceActivator invoiceActivator,
LinkGenerator linkGenerator,
AppService appService,
IFileService fileService,
IAuthorizationService authorizationService)
{
_displayFormatter = displayFormatter;
@ -104,93 +103,10 @@ namespace BTCPayServer.Controllers
_invoiceActivator = invoiceActivator;
_linkGenerator = linkGenerator;
_authorizationService = authorizationService;
_fileService = fileService;
_appService = appService;
}
internal async Task<DataWrapper<InvoiceResponse>> CreateInvoiceCore(BitpayCreateInvoiceRequest invoice,
StoreData store, string serverUrl, List<string>? additionalTags = null,
CancellationToken cancellationToken = default, Action<InvoiceEntity>? entityManipulator = null)
{
var entity = await CreateInvoiceCoreRaw(invoice, store, serverUrl, additionalTags, cancellationToken, entityManipulator);
var resp = entity.EntityToDTO();
return new DataWrapper<InvoiceResponse>(resp) { Facade = "pos/invoice" };
}
internal async Task<InvoiceEntity> CreateInvoiceCoreRaw(BitpayCreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string>? additionalTags = null, CancellationToken cancellationToken = default, Action<InvoiceEntity>? entityManipulator = null)
{
var storeBlob = store.GetStoreBlob();
var entity = _InvoiceRepository.CreateNewInvoice();
entity.ExpirationTime = invoice.ExpirationTime is DateTimeOffset v ? v : entity.InvoiceTime + storeBlob.InvoiceExpiration;
entity.MonitoringExpiration = entity.ExpirationTime + storeBlob.MonitoringExpiration;
if (entity.ExpirationTime - TimeSpan.FromSeconds(30.0) < entity.InvoiceTime)
{
throw new BitpayHttpException(400, "The expirationTime is set too soon");
}
entity.Metadata.OrderId = invoice.OrderId;
entity.Metadata.PosDataLegacy = invoice.PosData;
entity.ServerUrl = serverUrl;
entity.FullNotifications = invoice.FullNotifications || invoice.ExtendedNotifications;
entity.ExtendedNotifications = invoice.ExtendedNotifications;
entity.NotificationURLTemplate = invoice.NotificationURL;
entity.NotificationEmail = invoice.NotificationEmail;
if (additionalTags != null)
entity.InternalTags.AddRange(additionalTags);
FillBuyerInfo(invoice, entity);
var taxIncluded = invoice.TaxIncluded.HasValue ? invoice.TaxIncluded.Value : 0m;
var price = invoice.Price;
entity.Metadata.ItemCode = invoice.ItemCode;
entity.Metadata.ItemDesc = invoice.ItemDesc;
entity.Metadata.Physical = invoice.Physical;
entity.Metadata.TaxIncluded = invoice.TaxIncluded;
entity.Currency = invoice.Currency;
if (price is decimal vv)
{
entity.Price = vv;
entity.Type = InvoiceType.Standard;
}
else
{
entity.Price = 0m;
entity.Type = InvoiceType.TopUp;
}
entity.StoreSupportUrl = storeBlob.StoreSupportUrl;
entity.RedirectURLTemplate = invoice.RedirectURL ?? store.StoreWebsite;
entity.RedirectAutomatically =
invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically);
entity.RequiresRefundEmail = invoice.RequiresRefundEmail;
entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy);
IPaymentFilter? excludeFilter = null;
if (invoice.PaymentCurrencies?.Any() is true)
{
invoice.SupportedTransactionCurrencies ??=
new Dictionary<string, InvoiceSupportedTransactionCurrency>();
foreach (string paymentCurrency in invoice.PaymentCurrencies)
{
invoice.SupportedTransactionCurrencies.TryAdd(paymentCurrency,
new InvoiceSupportedTransactionCurrency() { Enabled = true });
}
}
if (invoice.SupportedTransactionCurrencies != null && invoice.SupportedTransactionCurrencies.Count != 0)
{
var supportedTransactionCurrencies = invoice.SupportedTransactionCurrencies
.Where(c => c.Value.Enabled)
.Select(c => PaymentMethodId.TryParse(c.Key, out var p) ? p : null)
.Where(c => c != null)
.ToHashSet();
excludeFilter = PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p));
}
entity.PaymentTolerance = storeBlob.PaymentTolerance;
entity.DefaultPaymentMethod = invoice.DefaultPaymentMethod;
entity.RequiresRefundEmail = invoice.RequiresRefundEmail;
return await CreateInvoiceCoreRaw(entity, store, excludeFilter, null, cancellationToken, entityManipulator);
}
internal async Task<InvoiceEntity> CreatePaymentRequestInvoice(Data.PaymentRequestData prData, decimal? amount, decimal amountDue, StoreData storeData, HttpRequest request, CancellationToken cancellationToken)
{
var id = prData.Id;
@ -228,7 +144,7 @@ namespace BTCPayServer.Controllers
public async Task<InvoiceEntity> CreateInvoiceCoreRaw(CreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string>? additionalTags = null, CancellationToken cancellationToken = default, Action<InvoiceEntity>? entityManipulator = null)
{
var storeBlob = store.GetStoreBlob();
var entity = _InvoiceRepository.CreateNewInvoice();
var entity = _InvoiceRepository.CreateNewInvoice(store.Id);
entity.ServerUrl = serverUrl;
entity.ExpirationTime = entity.InvoiceTime + (invoice.Checkout.Expiration ?? storeBlob.InvoiceExpiration);
entity.MonitoringExpiration = entity.ExpirationTime + (invoice.Checkout.Monitoring ?? storeBlob.MonitoringExpiration);
@ -278,6 +194,7 @@ namespace BTCPayServer.Controllers
if (string.IsNullOrEmpty(entity.Currency))
entity.Currency = storeBlob.DefaultCurrency;
entity.Currency = entity.Currency.Trim().ToUpperInvariant();
entity.Price = Math.Min(GreenfieldConstants.MaxAmount, entity.Price);
entity.Price = Math.Max(0.0m, entity.Price);
var currencyInfo = _CurrencyNameTable.GetNumberFormatInfo(entity.Currency, false);
if (currencyInfo != null)
@ -304,6 +221,7 @@ namespace BTCPayServer.Controllers
entity.RefundMail = entity.Metadata.BuyerEmail;
}
entity.Status = InvoiceStatusLegacy.New;
entity.UpdateTotals();
HashSet<CurrencyPair> currencyPairsToFetch = new HashSet<CurrencyPair>();
var rules = storeBlob.GetRateRules(_NetworkProvider);
var excludeFilter = storeBlob.GetExcludedPaymentMethods(); // Here we can compose filters from other origin with PaymentFilter.Any()
@ -392,7 +310,7 @@ namespace BTCPayServer.Controllers
}
using (logs.Measure("Saving invoice"))
{
entity = await _InvoiceRepository.CreateInvoiceAsync(store.Id, entity, additionalSearchTerms);
await _InvoiceRepository.CreateInvoiceAsync(entity, additionalSearchTerms);
foreach (var method in paymentMethods)
{
if (method.GetPaymentMethodDetails() is BitcoinLikeOnChainPaymentMethod bp)
@ -496,7 +414,7 @@ namespace BTCPayServer.Controllers
await fetchingByCurrencyPair[new CurrencyPair(supportedPaymentMethod.PaymentId.CryptoCode, criteria.Value.Currency)];
if (currentRateToCrypto?.BidAsk != null)
{
var amount = paymentMethod.Calculate().Due.GetValue(network as BTCPayNetwork);
var amount = paymentMethod.Calculate().Due;
var limitValueCrypto = criteria.Value.Value / currentRateToCrypto.BidAsk.Bid;
if (amount < limitValueCrypto && criteria.Above)
@ -537,45 +455,5 @@ namespace BTCPayServer.Controllers
}
return null;
}
private SpeedPolicy ParseSpeedPolicy(string transactionSpeed, SpeedPolicy defaultPolicy)
{
if (transactionSpeed == null)
return defaultPolicy;
var mappings = new Dictionary<string, SpeedPolicy>();
mappings.Add("low", SpeedPolicy.LowSpeed);
mappings.Add("low-medium", SpeedPolicy.LowMediumSpeed);
mappings.Add("medium", SpeedPolicy.MediumSpeed);
mappings.Add("high", SpeedPolicy.HighSpeed);
if (!mappings.TryGetValue(transactionSpeed, out SpeedPolicy policy))
policy = defaultPolicy;
return policy;
}
private void FillBuyerInfo(BitpayCreateInvoiceRequest req, InvoiceEntity invoiceEntity)
{
var buyerInformation = invoiceEntity.Metadata;
buyerInformation.BuyerAddress1 = req.BuyerAddress1;
buyerInformation.BuyerAddress2 = req.BuyerAddress2;
buyerInformation.BuyerCity = req.BuyerCity;
buyerInformation.BuyerCountry = req.BuyerCountry;
buyerInformation.BuyerEmail = req.BuyerEmail;
buyerInformation.BuyerName = req.BuyerName;
buyerInformation.BuyerPhone = req.BuyerPhone;
buyerInformation.BuyerState = req.BuyerState;
buyerInformation.BuyerZip = req.BuyerZip;
var buyer = req.Buyer;
if (buyer == null)
return;
buyerInformation.BuyerAddress1 ??= buyer.Address1;
buyerInformation.BuyerAddress2 ??= buyer.Address2;
buyerInformation.BuyerCity ??= buyer.City;
buyerInformation.BuyerCountry ??= buyer.country;
buyerInformation.BuyerEmail ??= buyer.email;
buyerInformation.BuyerName ??= buyer.Name;
buyerInformation.BuyerPhone ??= buyer.phone;
buyerInformation.BuyerState ??= buyer.State;
buyerInformation.BuyerZip ??= buyer.zip;
}
}
}

@ -3,6 +3,7 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
@ -109,24 +110,24 @@ namespace BTCPayServer
}
var blob = pp.GetBlob();
if (!blob.Currency.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase))
if (!_pullPaymentHostedService.SupportsLNURL(blob))
{
return NotFound();
}
var unit = blob.Currency == "SATS" ? LightMoneyUnit.Satoshi : LightMoneyUnit.BTC;
var progress = _pullPaymentHostedService.CalculatePullPaymentProgress(pp, DateTimeOffset.UtcNow);
var remaining = progress.Limit - progress.Completed - progress.Awaiting;
var request = new LNURLWithdrawRequest
{
MaxWithdrawable = LightMoney.FromUnit(remaining, LightMoneyUnit.BTC),
MaxWithdrawable = LightMoney.FromUnit(remaining, unit),
K1 = pullPaymentId,
BalanceCheck = new Uri(Request.GetCurrentUrl()),
CurrentBalance = LightMoney.FromUnit(remaining, LightMoneyUnit.BTC),
CurrentBalance = LightMoney.FromUnit(remaining, unit),
MinWithdrawable =
LightMoney.FromUnit(
Math.Min(await _lightningLikePayoutHandler.GetMinimumPayoutAmount(pmi, null), remaining),
LightMoneyUnit.BTC),
unit),
Tag = "withdrawRequest",
Callback = new Uri(Request.GetCurrentUrl()),
// It's not `pp.GetBlob().Description` because this would be HTML
@ -154,13 +155,13 @@ namespace BTCPayServer
return NotFound();
}
var claimResponse = await _pullPaymentHostedService.Claim(new ClaimRequest()
var claimResponse = await _pullPaymentHostedService.Claim(new ClaimRequest
{
Destination = new BoltInvoiceClaimDestination(pr, result),
PaymentMethodId = pmi,
PullPaymentId = pullPaymentId,
StoreId = pp.StoreId,
Value = result.MinimumAmount.ToDecimal(LightMoneyUnit.BTC)
Value = result.MinimumAmount.ToDecimal(unit)
});
if (claimResponse.Result != ClaimRequest.ClaimResult.Ok)
@ -295,7 +296,7 @@ namespace BTCPayServer
var createInvoice = new CreateInvoiceRequest()
{
Amount = item?.Price.Value,
Amount = item?.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup? null: item?.Price,
Currency = currencyCode,
Checkout = new InvoiceDataBase.CheckoutOptions()
{
@ -305,11 +306,11 @@ namespace BTCPayServer
HttpContext.Request.GetAbsoluteUri($"/apps/{app.Id}/pos"),
_ => null
}
}
},
AdditionalSearchTerms = new[] { AppService.GetAppSearchTerm(app) }
};
var invoiceMetadata = new InvoiceMetadata();
invoiceMetadata.OrderId = AppService.GetAppOrderId(app);
var invoiceMetadata = new InvoiceMetadata { OrderId = AppService.GetRandomOrderId() };
if (item != null)
{
invoiceMetadata.ItemCode = item.Id;
@ -317,7 +318,6 @@ namespace BTCPayServer
}
createInvoice.Metadata = invoiceMetadata.ToJObject();
return await GetLNURLRequest(
cryptoCode,
store,
@ -373,13 +373,52 @@ namespace BTCPayServer
return NotFound("Unknown username");
var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId);
var cryptoCode = "BTC";
if (store is null)
return NotFound("Unknown username");
if (GetLNUrlPaymentMethodId(cryptoCode, store, out var lnUrlMethod) is null)
return NotFound("LNUrl not available for store");
var blob = lightningAddressSettings.GetBlob();
return await GetLNURLRequest(
"BTC",
var lnurlRequest = new LNURLPayRequest()
{
Tag = "payRequest",
MinSendable = blob?.Min is decimal min ? new LightMoney(min, LightMoneyUnit.Satoshi) : null,
MaxSendable = blob?.Max is decimal max ? new LightMoney(max, LightMoneyUnit.Satoshi) : null,
CommentAllowed = lnUrlMethod.LUD12Enabled ? 2000 : 0
};
NormalizeSendable(lnurlRequest);
var lnUrlMetadata = new Dictionary<string, string>()
{
["text/identifier"] = $"{username}@{Request.Host}"
};
SetLNUrlDescriptionMetadata(lnUrlMetadata, store, store.GetStoreBlob(), null);
lnurlRequest.Metadata =
JsonConvert.SerializeObject(lnUrlMetadata.Select(kv => new[] { kv.Key, kv.Value }));
lnurlRequest.Callback = new Uri(_linkGenerator.GetUriByAction(
action: nameof(GetLNURLForLightningAddress),
controller: "UILNURL",
values: new { cryptoCode, username }, Request.Scheme, Request.Host, Request.PathBase));
lnurlRequest = await _pluginHookService.ApplyFilter("modify-lnurlp-request", lnurlRequest) as LNURLPayRequest;
return Ok(lnurlRequest);
}
[HttpGet("pay/lnaddress/{username}")]
[EnableCors(CorsPolicies.All)]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> GetLNURLForLightningAddress(string cryptoCode, string username, [FromQuery] long? amount = null, string comment = null)
{
var lightningAddressSettings = await _lightningAddressService.ResolveByAddress(username);
if (lightningAddressSettings is null || username is null)
return NotFound("Unknown username");
var blob = lightningAddressSettings.GetBlob();
var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId);
var result = await GetLNURLRequest(
cryptoCode,
store,
store.GetStoreBlob(),
new CreateInvoiceRequest()
@ -396,31 +435,44 @@ namespace BTCPayServer
{
{ "text/identifier", $"{username}@{Request.Host}" }
});
if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest)
return result;
var invoiceId = payRequest.Callback.AbsoluteUri.Split('/').Last();
return await GetLNURLForInvoice(invoiceId, cryptoCode, amount, comment);
}
[HttpGet("pay")]
[HttpGet("{storeId}/pay")]
[EnableCors(CorsPolicies.All)]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> GetLNUrlForStore(
string cryptoCode,
string storeId,
string currencyCode = null)
string currency = null,
string orderId = null,
decimal? amount = null)
{
var store = this.HttpContext.GetStoreData();
var store = await _storeRepository.FindStore(storeId);
if (store is null)
return NotFound();
var blob = store.GetStoreBlob();
var blob = store.GetStoreBlob();
if (!blob.AnyoneCanInvoice)
return NotFound("'Anyone can invoice' is turned off");
var metadata = new InvoiceMetadata();
if (!string.IsNullOrEmpty(orderId))
{
metadata.OrderId = orderId;
}
return await GetLNURLRequest(
cryptoCode,
store,
blob,
new CreateInvoiceRequest
{
Currency = currencyCode
Amount = amount,
Metadata = metadata.ToJObject(),
Currency = currency
});
}
@ -482,11 +534,7 @@ namespace BTCPayServer
if (!lnUrlMetadata.ContainsKey("text/plain"))
{
var invoiceDescription = blob.LightningDescriptionTemplate
.Replace("{StoreName}", store.StoreName ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{ItemDescription}", i.Metadata.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{OrderId}", i.Metadata.OrderId ?? "", StringComparison.OrdinalIgnoreCase);
lnUrlMetadata.Add("text/plain", invoiceDescription);
SetLNUrlDescriptionMetadata(lnUrlMetadata, store, blob, i.Metadata);
}
lnurlRequest.Tag = "payRequest";
@ -498,17 +546,12 @@ namespace BTCPayServer
lnurlRequest.Metadata = JsonConvert.SerializeObject(lnUrlMetadata.Select(kv => new[] { kv.Key, kv.Value }));
if (i.Type != InvoiceType.TopUp)
{
lnurlRequest.MinSendable = new LightMoney(pm.Calculate().Due.ToDecimal(MoneyUnit.Satoshi), LightMoneyUnit.Satoshi);
lnurlRequest.MinSendable = LightMoney.Coins(pm.Calculate().Due);
if (!allowOverpay)
lnurlRequest.MaxSendable = lnurlRequest.MinSendable;
}
// We don't think BTCPay handle well 0 sats payments, just in case make it minimum one sat.
if (lnurlRequest.MinSendable is null || lnurlRequest.MinSendable < LightMoney.Satoshis(1.0m))
lnurlRequest.MinSendable = LightMoney.Satoshis(1.0m);
if (lnurlRequest.MaxSendable is null)
lnurlRequest.MaxSendable = LightMoney.FromUnit(6.12m, LightMoneyUnit.BTC);
NormalizeSendable(lnurlRequest);
lnurlRequest = await _pluginHookService.ApplyFilter("modify-lnurlp-request", lnurlRequest) as LNURLPayRequest;
if (paymentMethodDetails.PayRequest is null)
@ -524,6 +567,25 @@ namespace BTCPayServer
return lnurlRequest;
}
private void SetLNUrlDescriptionMetadata(Dictionary<string, string> lnUrlMetadata, Data.StoreData store, StoreBlob blob, InvoiceMetadata invoiceMetadata)
{
var invoiceDescription = blob.LightningDescriptionTemplate
.Replace("{StoreName}", store.StoreName ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{ItemDescription}", invoiceMetadata?.ItemDesc ?? "", StringComparison.OrdinalIgnoreCase)
.Replace("{OrderId}", invoiceMetadata?.OrderId ?? "", StringComparison.OrdinalIgnoreCase);
lnUrlMetadata.Add("text/plain", invoiceDescription);
}
private static void NormalizeSendable(LNURLPayRequest lnurlRequest)
{
// We don't think BTCPay handle well 0 sats payments, just in case make it minimum one sat.
if (lnurlRequest.MinSendable is null || lnurlRequest.MinSendable < LightMoney.Satoshis(1.0m))
lnurlRequest.MinSendable = LightMoney.Satoshis(1.0m);
if (lnurlRequest.MaxSendable is null)
lnurlRequest.MaxSendable = LightMoney.FromUnit(6.12m, LightMoneyUnit.BTC);
}
PaymentMethodId GetLNUrlPaymentMethodId(string cryptoCode, Data.StoreData store, out LNURLPaySupportedPaymentMethod lnUrlSettings)
{
lnUrlSettings = null;

@ -506,57 +506,57 @@ namespace BTCPayServer.Controllers
{
public static readonly Dictionary<string, (string Title, string Description)> PermissionDescriptions = new Dictionary<string, (string Title, string Description)>()
{
{Policies.Unrestricted, ("Unrestricted access", "The app will have unrestricted access to your account.")},
{Policies.CanViewUsers, ("View users", "The app will be able to see all users on this server.")},
{Policies.CanCreateUser, ("Create new users", "The app will be able to create new users on this server.")},
{Policies.CanManageUsers, ("Manage users", "The app will be able to create/delete API keys for users.")},
{Policies.CanDeleteUser, ("Delete user", "The app will be able to delete the user to whom it is assigned. Admin users can delete any user without this permission.")},
{Policies.CanModifyStoreSettings, ("Modify your stores", "The app will be able to manage invoices on all your stores and modify their settings.")},
{$"{Policies.CanModifyStoreSettings}:", ("Manage selected stores", "The app will be able to manage invoices on the selected stores and modify their settings.")},
{Policies.CanViewCustodianAccounts, ("View exchange accounts linked to your stores", "The app will be able to see exchange accounts linked to your stores.")},
{$"{Policies.CanViewCustodianAccounts}:", ("View exchange accounts linked to selected stores", "The app will be able to see exchange accounts linked to the selected stores.")},
{Policies.CanManageCustodianAccounts, ("Manage exchange accounts linked to your stores", "The app will be able to modify exchange accounts linked to your stores.")},
{$"{Policies.CanManageCustodianAccounts}:", ("Manage exchange accounts linked to selected stores", "The app will be able to modify exchange accounts linked to selected stores.")},
{Policies.CanDepositToCustodianAccounts, ("Deposit funds to exchange accounts linked to your stores", "The app will be able to deposit funds to your exchange accounts.")},
{$"{Policies.CanDepositToCustodianAccounts}:", ("Deposit funds to exchange accounts linked to selected stores", "The app will be able to deposit funds to selected store's exchange accounts.")},
{Policies.CanWithdrawFromCustodianAccounts, ("Withdraw funds from exchange accounts to your store", "The app will be able to withdraw funds from your exchange accounts to your store.")},
{$"{Policies.CanWithdrawFromCustodianAccounts}:", ("Withdraw funds from selected store's exchange accounts", "The app will be able to withdraw funds from your selected store's exchange accounts.")},
{Policies.CanTradeCustodianAccount, ("Trade funds on your store's exchange accounts", "The app will be able to trade funds on your store's exchange accounts.")},
{$"{Policies.CanTradeCustodianAccount}:", ("Trade funds on selected store's exchange accounts", "The app will be able to trade funds on selected store's exchange accounts.")},
{Policies.CanModifyStoreWebhooks, ("Modify stores webhooks", "The app will modify the webhooks of all your stores.")},
{$"{Policies.CanModifyStoreWebhooks}:", ("Modify selected stores' webhooks", "The app will modify the webhooks of the selected stores.")},
{Policies.CanViewStoreSettings, ("View your stores", "The app will be able to view stores settings.")},
{$"{Policies.CanViewStoreSettings}:", ("View your stores", "The app will be able to view the selected stores' settings.")},
{Policies.CanModifyServerSettings, ("Manage your server", "The app will have total control on the server settings of your server.")},
{Policies.CanViewProfile, ("View your profile", "The app will be able to view your user profile.")},
{Policies.CanModifyProfile, ("Manage your profile", "The app will be able to view and modify your user profile.")},
{Policies.CanManageNotificationsForUser, ("Manage your notifications", "The app will be able to view and modify your user notifications.")},
{Policies.CanViewNotificationsForUser, ("View your notifications", "The app will be able to view your user notifications.")},
{Policies.CanCreateInvoice, ("Create an invoice", "The app will be able to create new invoices.")},
{$"{Policies.CanCreateInvoice}:", ("Create an invoice", "The app will be able to create new invoices on the selected stores.")},
{Policies.CanViewInvoices, ("View invoices", "The app will be able to view invoices.")},
{Policies.CanModifyInvoices, ("Modify invoices", "The app will be able to modify and view invoices.")},
{$"{Policies.CanViewInvoices}:", ("View invoices", "The app will be able to view invoices on the selected stores.")},
{$"{Policies.CanModifyInvoices}:", ("Modify invoices", "The app will be able to modify and view invoices on the selected stores.")},
{Policies.CanModifyPaymentRequests, ("Modify your payment requests", "The app will be able to view, modify, delete and create new payment requests on all your stores.")},
{$"{Policies.CanModifyPaymentRequests}:", ("Manage selected stores' payment requests", "The app will be able to view, modify, delete and create new payment requests on the selected stores.")},
{Policies.CanViewPaymentRequests, ("View your payment requests", "The app will be able to view payment requests.")},
{$"{Policies.CanViewPaymentRequests}:", ("View your payment requests", "The app will be able to view the selected stores' payment requests.")},
{Policies.CanManagePullPayments, ("Manage your pull payments", "The app will be able to view, modify, delete and create pull payments on all your stores.")},
{$"{Policies.CanManagePullPayments}:", ("Manage selected stores' pull payments", "The app will be able to view, modify, delete and create new pull payments on the selected stores.")},
{Policies.CanCreatePullPayments, ("Create pull payments", "The app will be able to create pull payments on all your stores.")},
{$"{Policies.CanCreatePullPayments}:", ("Create pull payments in selected stores", "The app will be able to create new pull payments on the selected stores.")},
{Policies.CanCreateNonApprovedPullPayments, ("Create non-approved pull payments", "The app will be able to create pull payments without automatic approval on all your stores.")},
{$"{Policies.CanCreateNonApprovedPullPayments}:", ("Create non-approved pull payments in selected stores", "The app will be able to view, modify, delete and create pull payments without automatic approval on the selected stores.")},
{Policies.CanUseInternalLightningNode, ("Use the internal lightning node", "The app will be able to use the internal BTCPay Server lightning node to create BOLT11 invoices, connect to other nodes, open new channels and pay BOLT11 invoices.")},
{Policies.CanViewLightningInvoiceInternalNode, ("View invoices from internal lightning node", "The app will be able to use the internal BTCPay Server lightning node to view BOLT11 invoices.")},
{Policies.CanCreateLightningInvoiceInternalNode, ("Create invoices with internal lightning node", "The app will be able to use the internal BTCPay Server lightning node to create BOLT11 invoices.")},
{Policies.CanUseLightningNodeInStore, ("Use the lightning nodes associated with your stores", "The app will be able to use the lightning nodes connected to all your stores to create BOLT11 invoices, connect to other nodes, open new channels and pay BOLT11 invoices.")},
{Policies.CanViewLightningInvoiceInStore, ("View the lightning invoices associated with your stores", "The app will be able to view the lightning invoices connected to all your stores.")},
{Policies.CanCreateLightningInvoiceInStore, ("Create invoices from the lightning nodes associated with your stores", "The app will be able to use the lightning nodes connected to all your stores to create BOLT11 invoices.")},
{$"{Policies.CanUseLightningNodeInStore}:", ("Use the lightning nodes associated with your stores", "The app will be able to use the lightning nodes connected to the selected stores to create BOLT11 invoices, connect to other nodes, open new channels and pay BOLT11 invoices.")},
{$"{Policies.CanViewLightningInvoiceInStore}:", ("View the lightning invoices associated with your stores", "The app will be able to view the lightning invoices connected to the selected stores.")},
{$"{Policies.CanCreateLightningInvoiceInStore}:", ("Create invoices from the lightning nodes associated with your stores", "The app will be able to use the lightning nodes connected to the selected stores to create BOLT11 invoices.")},
{Policies.Unrestricted, ("Unrestricted access", "Grants unrestricted access to your account.")},
{Policies.CanViewUsers, ("View users", "Allows seeing all users on this server.")},
{Policies.CanCreateUser, ("Create new users", "Allows creating new users on this server.")},
{Policies.CanManageUsers, ("Manage users", "Allows creating/deleting API keys for users.")},
{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.CanViewCustodianAccounts, ("View exchange accounts linked to your stores", "Allows seeing exchange accounts linked to your stores.")},
{$"{Policies.CanViewCustodianAccounts}:", ("View exchange accounts linked to selected stores", "Allows seeing exchange accounts linked to the selected stores.")},
{Policies.CanManageCustodianAccounts, ("Manage exchange accounts linked to your stores", "Allows modifying exchange accounts linked to your stores.")},
{$"{Policies.CanManageCustodianAccounts}:", ("Manage exchange accounts linked to selected stores", "Allows modifying exchange accounts linked to selected stores.")},
{Policies.CanDepositToCustodianAccounts, ("Deposit funds to exchange accounts linked to your stores", "Allows depositing funds to your exchange accounts.")},
{$"{Policies.CanDepositToCustodianAccounts}:", ("Deposit funds to exchange accounts linked to selected stores", "Allows depositing funds to selected store's exchange accounts.")},
{Policies.CanWithdrawFromCustodianAccounts, ("Withdraw funds from exchange accounts to your store", "Allows withdrawing funds from your exchange accounts to your store.")},
{$"{Policies.CanWithdrawFromCustodianAccounts}:", ("Withdraw funds from selected store's exchange accounts", "Allows withdrawing funds from your selected store's exchange accounts.")},
{Policies.CanTradeCustodianAccount, ("Trade funds on your store's exchange accounts", "Allows trading funds on your store's exchange accounts.")},
{$"{Policies.CanTradeCustodianAccount}:", ("Trade funds on selected store's exchange accounts", "Allows trading funds on selected store's exchange accounts.")},
{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.CanViewStoreSettings, ("View your stores", "Allows viewing stores settings.")},
{$"{Policies.CanViewStoreSettings}:", ("View your stores", "Allows viewing the selected stores' settings.")},
{Policies.CanModifyServerSettings, ("Manage your server", "Grants total control on the server settings of your server.")},
{Policies.CanViewProfile, ("View your profile", "Allows viewing your user profile.")},
{Policies.CanModifyProfile, ("Manage your profile", "Allows viewing and modifying your user profile.")},
{Policies.CanManageNotificationsForUser, ("Manage your notifications", "Allows viewing and modifying your user notifications.")},
{Policies.CanViewNotificationsForUser, ("View your notifications", "Allows viewing your user notifications.")},
{Policies.CanCreateInvoice, ("Create an invoice", "Allows creating new invoices.")},
{$"{Policies.CanCreateInvoice}:", ("Create an invoice", "Allows creating new invoices on the selected stores.")},
{Policies.CanViewInvoices, ("View invoices", "Allows viewing invoices.")},
{Policies.CanModifyInvoices, ("Modify invoices", "Allows viewing and modifying invoices.")},
{$"{Policies.CanViewInvoices}:", ("View invoices", "Allows viewing invoices on the selected stores.")},
{$"{Policies.CanModifyInvoices}:", ("Modify invoices", "Allows viewing and modifying invoices on the selected stores.")},
{Policies.CanModifyPaymentRequests, ("Modify your payment requests", "Allows viewing, modifying, deleting and creating new payment requests on all your stores.")},
{$"{Policies.CanModifyPaymentRequests}:", ("Manage selected stores' payment requests", "Allows viewing, modifying, deleting and creating new payment requests on the selected stores.")},
{Policies.CanViewPaymentRequests, ("View your payment requests", "Allows viewing payment requests.")},
{$"{Policies.CanViewPaymentRequests}:", ("View your payment requests", "Allows viewing the selected stores' payment requests.")},
{Policies.CanManagePullPayments, ("Manage your pull payments", "Allows viewing, modifying, deleting and creating pull payments on all your stores.")},
{$"{Policies.CanManagePullPayments}:", ("Manage selected stores' pull payments", "Allows viewing, modifying, deleting and creating pull payments on the selected stores.")},
{Policies.CanCreatePullPayments, ("Create pull payments", "Allows creating pull payments on all your stores.")},
{$"{Policies.CanCreatePullPayments}:", ("Create pull payments in selected stores", "Allows creating pull payments on the selected stores.")},
{Policies.CanCreateNonApprovedPullPayments, ("Create non-approved pull payments", "Allows creating pull payments without automatic approval on all your stores.")},
{$"{Policies.CanCreateNonApprovedPullPayments}:", ("Create non-approved pull payments in selected stores", "Allows viewing, modifying, deleting and creating pull payments without automatic approval on the selected stores.")},
{Policies.CanUseInternalLightningNode, ("Use the internal lightning node", "Allows using the internal BTCPay Server lightning node to create BOLT11 invoices, connect to other nodes, open new channels and pay BOLT11 invoices.")},
{Policies.CanViewLightningInvoiceInternalNode, ("View invoices from internal lightning node", "Allows using the internal BTCPay Server lightning node to view BOLT11 invoices.")},
{Policies.CanCreateLightningInvoiceInternalNode, ("Create invoices with internal lightning node", "Allows using the internal BTCPay Server lightning node to create BOLT11 invoices.")},
{Policies.CanUseLightningNodeInStore, ("Use the lightning nodes associated with your stores", "Allows using the lightning nodes connected to all your stores to create BOLT11 invoices, connect to other nodes, open new channels and pay BOLT11 invoices.")},
{Policies.CanViewLightningInvoiceInStore, ("View the lightning invoices associated with your stores", "Allows viewing the lightning invoices connected to all your stores.")},
{Policies.CanCreateLightningInvoiceInStore, ("Create invoices from the lightning nodes associated with your stores", "Allows using the lightning nodes connected to all your stores to create BOLT11 invoices.")},
{$"{Policies.CanUseLightningNodeInStore}:", ("Use the lightning nodes associated with your stores", "Allows using the lightning nodes connected to the selected stores to create BOLT11 invoices, connect to other nodes, open new channels and pay BOLT11 invoices.")},
{$"{Policies.CanViewLightningInvoiceInStore}:", ("View the lightning invoices associated with your stores", "Allows viewing the lightning invoices connected to the selected stores.")},
{$"{Policies.CanCreateLightningInvoiceInStore}:", ("Create invoices from the lightning nodes associated with your stores", "Allows using the lightning nodes connected to the selected stores to create BOLT11 invoices.")},
};
public string Title
{

@ -78,16 +78,20 @@ namespace BTCPayServer.Controllers
model = this.ParseListQuery(model ?? new ListPaymentRequestsViewModel());
var store = GetCurrentStore();
var includeArchived = new SearchString(model.SearchTerm, model.TimezoneOffset ?? 0).GetFilterBool("includearchived") == true;
var fs = new SearchString(model.SearchTerm, model.TimezoneOffset ?? 0);
var result = await _PaymentRequestRepository.FindPaymentRequests(new PaymentRequestQuery
{
UserId = GetUserId(),
StoreId = store.Id,
Skip = model.Skip,
Count = model.Count,
IncludeArchived = includeArchived
Status = fs.GetFilterArray("status")?.Select(s => Enum.Parse<Client.Models.PaymentRequestData.PaymentRequestStatus>(s, true)).ToArray(),
IncludeArchived = fs.GetFilterBool("includearchived") ?? false
});
model.Search = fs;
model.SearchText = fs.TextSearch;
model.Items = result.Select(data =>
{
var blob = data.GetBlob();

@ -3,12 +3,16 @@ using System.Threading;
using System.Threading.Tasks;
using System.Web;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.Greenfield;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Plugins.PayButton.Models;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using NicolasDorier.RateLimits;
namespace BTCPayServer.Controllers
@ -16,14 +20,17 @@ namespace BTCPayServer.Controllers
public class UIPublicController : Controller
{
public UIPublicController(UIInvoiceController invoiceController,
StoreRepository storeRepository)
StoreRepository storeRepository,
LinkGenerator linkGenerator)
{
_InvoiceController = invoiceController;
_StoreRepository = storeRepository;
_linkGenerator = linkGenerator;
}
private readonly UIInvoiceController _InvoiceController;
private readonly StoreRepository _StoreRepository;
private readonly LinkGenerator _linkGenerator;
[HttpGet]
[IgnoreAntiforgeryToken]
@ -57,21 +64,31 @@ namespace BTCPayServer.Controllers
if (!ModelState.IsValid)
return View();
DataWrapper<InvoiceResponse> invoice = null;
InvoiceEntity invoice = null;
try
{
invoice = await _InvoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest()
invoice = await _InvoiceController.CreateInvoiceCoreRaw(new CreateInvoiceRequest()
{
Price = model.Price,
Amount = model.Price,
Currency = model.Currency,
ItemDesc = model.CheckoutDesc,
OrderId = model.OrderId,
NotificationEmail = model.NotifyEmail,
NotificationURL = model.ServerIpn,
RedirectURL = model.BrowserRedirect,
FullNotifications = true,
DefaultPaymentMethod = model.DefaultPaymentMethod
}, store, HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
Metadata = new InvoiceMetadata()
{
ItemDesc = model.CheckoutDesc,
OrderId = model.OrderId
}.ToJObject(),
Checkout = new ()
{
RedirectURL = model.BrowserRedirect ?? store?.StoreWebsite,
DefaultPaymentMethod = model.DefaultPaymentMethod
}
}, store, HttpContext.Request.GetAbsoluteRoot(),
entityManipulator: (entity) =>
{
entity.NotificationEmail = model.NotifyEmail;
entity.NotificationURLTemplate = model.ServerIpn;
entity.FullNotifications = true;
},
cancellationToken: cancellationToken);
}
catch (BitpayHttpException e)
{
@ -84,26 +101,25 @@ namespace BTCPayServer.Controllers
return View();
}
var url = GreenfieldInvoiceController.ToModel(invoice, _linkGenerator, HttpContext.Request).CheckoutLink;
if (!string.IsNullOrEmpty(model.CheckoutQueryString))
{
var additionalParamValues = HttpUtility.ParseQueryString(model.CheckoutQueryString);
var uriBuilder = new UriBuilder(url);
var paramValues = HttpUtility.ParseQueryString(uriBuilder.Query);
paramValues.Add(additionalParamValues);
uriBuilder.Query = paramValues.ToString()!;
url = uriBuilder.Uri.AbsoluteUri;
}
if (model.JsonResponse)
{
return Json(new
{
InvoiceId = invoice.Data.Id,
InvoiceUrl = invoice.Data.Url
InvoiceId = invoice.Id,
InvoiceUrl = url
});
}
if (string.IsNullOrEmpty(model.CheckoutQueryString))
{
return Redirect(invoice.Data.Url);
}
var additionalParamValues = HttpUtility.ParseQueryString(model.CheckoutQueryString);
var uriBuilder = new UriBuilder(invoice.Data.Url);
var paramValues = HttpUtility.ParseQueryString(uriBuilder.Query);
paramValues.Add(additionalParamValues);
uriBuilder.Query = paramValues.ToString();
return Redirect(uriBuilder.Uri.AbsoluteUri);
return Redirect(url);
}
}
}

@ -29,6 +29,7 @@ namespace BTCPayServer.Controllers
private readonly CurrencyNameTable _currencyNameTable;
private readonly DisplayFormatter _displayFormatter;
private readonly PullPaymentHostedService _pullPaymentHostedService;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings;
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
private readonly StoreRepository _storeRepository;
@ -37,6 +38,7 @@ namespace BTCPayServer.Controllers
CurrencyNameTable currencyNameTable,
DisplayFormatter displayFormatter,
PullPaymentHostedService pullPaymentHostedService,
BTCPayNetworkProvider networkProvider,
BTCPayNetworkJsonSerializerSettings serializerSettings,
IEnumerable<IPayoutHandler> payoutHandlers,
StoreRepository storeRepository)
@ -48,6 +50,7 @@ namespace BTCPayServer.Controllers
_serializerSettings = serializerSettings;
_payoutHandlers = payoutHandlers;
_storeRepository = storeRepository;
_networkProvider = networkProvider;
}
[AllowAnonymous]
@ -102,6 +105,13 @@ namespace BTCPayServer.Controllers
}).ToList()
};
vm.IsPending &= vm.AmountDue > 0.0m;
if (_pullPaymentHostedService.SupportsLNURL(blob))
{
var url = Url.Action("GetLNURLForPullPayment", "UILNURL", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, pullPaymentId = vm.Id }, Request.Scheme, Request.Host.ToString());
vm.LnurlEndpoint = url != null ? new Uri(url) : null;
}
return View(nameof(ViewPullPayment), vm);
}
@ -189,21 +199,15 @@ namespace BTCPayServer.Controllers
ModelState.AddModelError(nameof(vm.Destination), destination.error ?? "Invalid destination with selected payment method");
return await ViewPullPayment(pullPaymentId);
}
if (vm.ClaimedAmount == 0)
var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, vm.ClaimedAmount == 0? null: vm.ClaimedAmount, paymentMethodId.CryptoCode, ppBlob.Currency);
if (amtError.error is not null)
{
ModelState.AddModelError(nameof(vm.ClaimedAmount), "Amount is required");
ModelState.AddModelError(nameof(vm.ClaimedAmount), amtError.error );
}
else
else if (amtError.amount is not null)
{
var amount = ppBlob.Currency == "SATS" ? new Money(vm.ClaimedAmount, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) : vm.ClaimedAmount;
if (destination.destination.Amount != null && amount != destination.destination.Amount)
{
var implied = _displayFormatter.Currency(destination.destination.Amount.Value, paymentMethodId.CryptoCode, DisplayFormatter.CurrencyFormat.Symbol);
var provided = _displayFormatter.Currency(vm.ClaimedAmount, ppBlob.Currency, DisplayFormatter.CurrencyFormat.Symbol);
ModelState.AddModelError(nameof(vm.ClaimedAmount),
$"Amount implied in destination ({implied}) does not match the payout amount provided ({provided}).");
}
vm.ClaimedAmount = amtError.amount.Value;
}
if (!ModelState.IsValid)

@ -0,0 +1,122 @@
#nullable enable
using System;
using Dapper;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.GreenField;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Models.StoreReportsViewModels;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Internal;
using System.Text.Json.Nodes;
using Org.BouncyCastle.Ocsp;
using System.Threading;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using NBitcoin.DataEncoders;
using System.Net;
namespace BTCPayServer.Controllers;
public partial class UIReportsController
{
private IList<IList<object?>> Generate(IList<StoreReportResponse.Field> fields)
{
var rand = new Random();
int rowCount = 1_000;
List<object?> row = new List<object?>();
List<IList<object?>> result = new List<IList<object?>>();
for (int i = 0; i < rowCount; i++)
{
int fi = 0;
foreach (var f in fields)
{
row.Add(GenerateData(fields, f, fi, row, rand));
fi++;
}
result.Add(row);
row = new List<object?>();
}
return result;
}
private object? GenerateData(IList<StoreReportResponse.Field> fields, StoreReportResponse.Field f, int fi, List<object?> row, Random rand)
{
byte[] GenerateBytes(int count)
{
var bytes = new byte[count];
rand.NextBytes(bytes);
return bytes;
}
T TakeOne<T>(params T[] v)
{
return v[rand.NextInt64(0, v.Length)];
}
decimal GenerateDecimal(decimal from, decimal to, int precision)
{
decimal range = to - from;
decimal randomValue = ((decimal)rand.NextDouble() * range) + from;
return decimal.Round(randomValue, precision);
}
if (f.Type == "invoice_id")
return Encoders.Base58.EncodeData(GenerateBytes(20));
if (f.Type == "boolean")
return GenerateBytes(1)[0] % 2 == 0;
if (f.Name == "PaymentType")
return TakeOne("On-Chain", "Lightning");
if (f.Name == "PaymentId")
if (row[fi -1] is "On-Chain")
return Encoders.Hex.EncodeData(GenerateBytes(32)) + "-" + rand.NextInt64(0, 4);
else
return Encoders.Hex.EncodeData(GenerateBytes(32));
if (f.Name == "Address")
return Encoders.Bech32("bc1").Encode(0, GenerateBytes(20));
if (f.Name == "Crypto")
return rand.NextSingle() > 0.2 ? "BTC" : TakeOne("LTC", "DOGE", "DASH");
if (f.Name == "CryptoAmount")
return GenerateDecimal(0.1m, 5m, 8);
if (f.Name == "LightningAddress")
return TakeOne("satoshi", "satosan", "satoichi") + "@bitcoin.org";
if (f.Name == "BalanceChange")
return GenerateDecimal(-5.0m, 5.0m, 8);
if (f.Type == "datetime")
return DateTimeOffset.UtcNow - TimeSpan.FromHours(rand.Next(0, 24 * 30 * 6)) - TimeSpan.FromMinutes(rand.Next(0, 60));
if (f.Name == "Product")
return TakeOne("green-tea", "black-tea", "oolong-tea", "coca-cola");
if (f.Name == "State")
return TakeOne("Settled", "Processing");
if (f.Name == "AppId")
return TakeOne("AppA", "AppB");
if (f.Name == "Quantity")
return TakeOne(1, 2, 3, 4, 5);
if (f.Name == "Currency")
return rand.NextSingle() > 0.2 ? "USD" : TakeOne("JPY", "EUR", "CHF");
if (f.Name == "CurrencyAmount")
return row[fi - 1] switch
{
"USD" or "EUR" or "CHF" => GenerateDecimal(100.0m, 10_000m, 2),
"JPY" => GenerateDecimal(10_000m, 1000_0000, 0),
_ => GenerateDecimal(100.0m, 10_000m, 2)
};
if (f.Type == "tx_id")
return Encoders.Hex.EncodeData(GenerateBytes(32));
if (f.Name == "Rate")
{
return row[fi - 1] switch
{
"USD" or "EUR" or "CHF" => GenerateDecimal(30_000m, 60_000, 2),
"JPY" => GenerateDecimal(400_0000m, 1000_0000m, 0),
_ => GenerateDecimal(30_000m, 60_000, 2)
};
}
return null;
}
}

@ -0,0 +1,91 @@
#nullable enable
using System;
using Dapper;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.GreenField;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Models.StoreReportsViewModels;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Internal;
using System.Text.Json.Nodes;
using Org.BouncyCastle.Ocsp;
using System.Threading;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using NBitcoin.DataEncoders;
namespace BTCPayServer.Controllers;
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[AutoValidateAntiforgeryToken]
public partial class UIReportsController : Controller
{
public UIReportsController(
BTCPayNetworkProvider networkProvider,
ApplicationDbContextFactory dbContextFactory,
GreenfieldReportsController api,
ReportService reportService,
BTCPayServerEnvironment env
)
{
Api = api;
ReportService = reportService;
Env = env;
DBContextFactory = dbContextFactory;
NetworkProvider = networkProvider;
}
private BTCPayNetworkProvider NetworkProvider { get; }
public GreenfieldReportsController Api { get; }
public ReportService ReportService { get; }
public BTCPayServerEnvironment Env { get; }
public ApplicationDbContextFactory DBContextFactory { get; }
[HttpPost("stores/{storeId}/reports")]
[AcceptMediaTypeConstraint("application/json")]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> StoreReportsJson(string storeId, [FromBody] StoreReportRequest? request = null, bool fakeData = false, CancellationToken cancellation = default)
{
var result = await Api.StoreReports(storeId, request, cancellation);
if (fakeData && Env.CheatMode)
{
var r = (StoreReportResponse)((JsonResult)result!).Value!;
r.Data = Generate(r.Fields).Select(r => new JArray(r)).ToList();
}
return result;
}
[HttpGet("stores/{storeId}/reports")]
[AcceptMediaTypeConstraint("text/html")]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult StoreReports(
string storeId,
string ? viewName = null)
{
var vm = new StoreReportsViewModel()
{
InvoiceTemplateUrl = this.Url.Action(nameof(UIInvoiceController.Invoice), "UIInvoice", new { invoiceId = "INVOICE_ID" }),
ExplorerTemplateUrls = NetworkProvider.GetAll().ToDictionary(network => network.CryptoCode, network => network.BlockExplorerLink?.Replace("{0}", "TX_ID")),
Request = new StoreReportRequest()
{
ViewName = viewName ?? "Payments"
}
};
vm.AvailableViews = ReportService.ReportProviders
.Values
.Where(r => r.IsAvailable())
.Select(k => k.Name)
.OrderBy(k => k).ToList();
return View(vm);
}
}

@ -29,6 +29,7 @@ using BTCPayServer.Storage.Services;
using BTCPayServer.Storage.Services.Providers;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
@ -664,6 +665,8 @@ namespace BTCPayServer.Controllers
[Route("lnd-config/{configKey}/lnd.config")]
[AllowAnonymous]
[EnableCors(CorsPolicies.All)]
[IgnoreAntiforgeryToken]
public IActionResult GetLNDConfig(ulong configKey)
{
var conf = _LnConfigProvider.GetConfig(configKey);
@ -1047,29 +1050,28 @@ namespace BTCPayServer.Controllers
{
if (model.LogoFile.Length > 1_000_000)
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file should be less than 1MB";
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file should be less than 1MB");
}
else if (!model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
}
else
{
var formFile = await model.LogoFile.Bufferize();
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
}
else
{
model.LogoFile = formFile;
// delete existing image
// delete existing file
if (!string.IsNullOrEmpty(settings.LogoFileId))
{
await _fileService.RemoveFile(settings.LogoFileId, userId);
}
// add new image
// add new file
try
{
var storedFile = await _fileService.AddFile(model.LogoFile, userId);

@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json.Linq;
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
using PayoutData = BTCPayServer.Data.PayoutData;
using PullPaymentData = BTCPayServer.Data.PullPaymentData;
@ -529,10 +530,32 @@ namespace BTCPayServer.Controllers
{
var ppBlob = item.PullPayment?.GetBlob();
var payoutBlob = item.Payout.GetBlob(_jsonSerializerSettings);
string payoutSource;
if (payoutBlob.Metadata?.TryGetValue("source", StringComparison.InvariantCultureIgnoreCase,
out var source) is true)
{
payoutSource = source.Value<string>();
}
else
{
payoutSource = ppBlob?.Name ?? item.PullPayment?.Id;
}
string payoutSourceLink = null;
if (payoutBlob.Metadata?.TryGetValue("sourceLink", StringComparison.InvariantCultureIgnoreCase,
out var sourceLink) is true)
{
payoutSourceLink = sourceLink.Value<string>();
}
else if(item.PullPayment?.Id is not null)
{
payoutSourceLink = Url.Action("ViewPullPayment", "UIPullPayment", new { pullPaymentId = item.PullPayment?.Id });
}
var m = new PayoutsModel.PayoutModel
{
PullPaymentId = item.PullPayment?.Id,
PullPaymentName = ppBlob?.Name ?? item.PullPayment?.Id,
Source = payoutSource,
SourceLink = payoutSourceLink,
Date = item.Payout.Date,
PayoutId = item.Payout.Id,
Amount = _displayFormatter.Currency(payoutBlob.Amount, ppBlob?.Currency ?? PaymentMethodId.Parse(item.Payout.PaymentMethodId).CryptoCode),

@ -392,6 +392,7 @@ namespace BTCPayServer.Controllers
vm.UseClassicCheckout = storeBlob.CheckoutType == Client.Models.CheckoutType.V1;
vm.CelebratePayment = storeBlob.CelebratePayment;
vm.PlaySoundOnPayment = storeBlob.PlaySoundOnPayment;
vm.OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback;
vm.ShowPayInWalletButton = storeBlob.ShowPayInWalletButton;
vm.ShowStoreHeader = storeBlob.ShowStoreHeader;
@ -401,6 +402,7 @@ namespace BTCPayServer.Controllers
vm.RedirectAutomatically = storeBlob.RedirectAutomatically;
vm.CustomCSS = storeBlob.CustomCSS;
vm.CustomLogo = storeBlob.CustomLogo;
vm.SoundFileId = storeBlob.SoundFileId;
vm.HtmlTitle = storeBlob.HtmlTitle;
vm.DisplayExpirationTimer = (int)storeBlob.DisplayExpirationTimer.TotalMinutes;
vm.ReceiptOptions = CheckoutAppearanceViewModel.ReceiptOptionsViewModel.Create(storeBlob.ReceiptOptions);
@ -450,7 +452,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/checkout")]
public async Task<IActionResult> CheckoutAppearance(CheckoutAppearanceViewModel model)
public async Task<IActionResult> CheckoutAppearance(CheckoutAppearanceViewModel model, [FromForm] bool RemoveSoundFile = false)
{
bool needUpdate = false;
var blob = CurrentStore.GetStoreBlob();
@ -475,6 +477,57 @@ namespace BTCPayServer.Controllers
}
}
}
var userId = GetUserId();
if (userId is null)
return NotFound();
if (model.SoundFile != null)
{
if (model.SoundFile.Length > 1_000_000)
{
ModelState.AddModelError(nameof(model.SoundFile), "The uploaded sound file should be less than 1MB");
}
else if (!model.SoundFile.ContentType.StartsWith("audio/", StringComparison.InvariantCulture))
{
ModelState.AddModelError(nameof(model.SoundFile), "The uploaded sound file needs to be an audio file");
}
else
{
var formFile = await model.SoundFile.Bufferize();
if (!FileTypeDetector.IsAudio(formFile.Buffer, formFile.FileName))
{
ModelState.AddModelError(nameof(model.SoundFile), "The uploaded sound file needs to be an audio file");
}
else
{
model.SoundFile = formFile;
// delete existing file
if (!string.IsNullOrEmpty(blob.SoundFileId))
{
await _fileService.RemoveFile(blob.SoundFileId, userId);
}
// add new file
try
{
var storedFile = await _fileService.AddFile(model.SoundFile, userId);
blob.SoundFileId = storedFile.Id;
needUpdate = true;
}
catch (Exception e)
{
ModelState.AddModelError(nameof(model.SoundFile), $"Could not save sound: {e.Message}");
}
}
}
}
else if (RemoveSoundFile && !string.IsNullOrEmpty(blob.SoundFileId))
{
await _fileService.RemoveFile(blob.SoundFileId, userId);
blob.SoundFileId = null;
needUpdate = true;
}
if (!ModelState.IsValid)
{
@ -516,6 +569,7 @@ namespace BTCPayServer.Controllers
blob.ShowStoreHeader = model.ShowStoreHeader;
blob.CheckoutType = model.UseClassicCheckout ? Client.Models.CheckoutType.V1 : Client.Models.CheckoutType.V2;
blob.CelebratePayment = model.CelebratePayment;
blob.PlaySoundOnPayment = model.PlaySoundOnPayment;
blob.OnChainWithLnInvoiceFallback = model.OnChainWithLnInvoiceFallback;
blob.LightningAmountInSatoshi = model.LightningAmountInSatoshi;
blob.RequiresRefundEmail = model.RequiresRefundEmail;
@ -674,28 +728,27 @@ namespace BTCPayServer.Controllers
{
if (model.LogoFile.Length > 1_000_000)
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file should be less than 1MB";
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file should be less than 1MB");
}
else if (!model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
}
else
{
var formFile = await model.LogoFile.Bufferize();
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
}
else
{
model.LogoFile = formFile;
// delete existing image
// delete existing file
if (!string.IsNullOrEmpty(blob.LogoFileId))
{
await _fileService.RemoveFile(blob.LogoFileId, userId);
}
// add new image
try
{
@ -704,7 +757,7 @@ namespace BTCPayServer.Controllers
}
catch (Exception e)
{
TempData[WellKnownTempData.ErrorMessage] = $"Could not save logo: {e.Message}";
ModelState.AddModelError(nameof(model.LogoFile), $"Could not save logo: {e.Message}");
}
}
}
@ -720,25 +773,24 @@ namespace BTCPayServer.Controllers
{
if (model.CssFile.Length > 1_000_000)
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded file should be less than 1MB";
ModelState.AddModelError(nameof(model.CssFile), "The uploaded file should be less than 1MB");
}
else if (!model.CssFile.ContentType.Equals("text/css", StringComparison.InvariantCulture))
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded file needs to be a CSS file";
ModelState.AddModelError(nameof(model.CssFile), "The uploaded file needs to be a CSS file");
}
else if (!model.CssFile.FileName.EndsWith(".css", StringComparison.OrdinalIgnoreCase))
{
TempData[WellKnownTempData.ErrorMessage] = "The uploaded file needs to be a CSS file";
ModelState.AddModelError(nameof(model.CssFile), "The uploaded file needs to be a CSS file");
}
else
{
// delete existing CSS file
// delete existing file
if (!string.IsNullOrEmpty(blob.CssFileId))
{
await _fileService.RemoveFile(blob.CssFileId, userId);
}
// add new CSS file
// add new file
try
{
var storedFile = await _fileService.AddFile(model.CssFile, userId);
@ -746,7 +798,7 @@ namespace BTCPayServer.Controllers
}
catch (Exception e)
{
TempData[WellKnownTempData.ErrorMessage] = $"Could not save CSS file: {e.Message}";
ModelState.AddModelError(nameof(model.CssFile), $"Could not save CSS file: {e.Message}");
}
}
}

@ -39,12 +39,12 @@ namespace BTCPayServer.Controllers
[HttpGet("create")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettingsUnscoped)]
public async Task<IActionResult> CreateStore()
public async Task<IActionResult> CreateStore(bool skipWizard)
{
var stores = await _repo.GetStoresByUserId(GetUserId());
var vm = new CreateStoreViewModel
{
IsFirstStore = !stores.Any(),
IsFirstStore = !(stores.Any() || skipWizard),
DefaultCurrency = StoreBlob.StandardDefaultCurrency,
Exchanges = GetExchangesSelectList(null)
};

@ -212,7 +212,9 @@ namespace BTCPayServer.Controllers
WalletId walletId,
string? labelFilter = null,
int skip = 0,
int count = 50
int count = 50,
bool loadTransactions = false,
CancellationToken cancellationToken = default
)
{
var paymentMethod = GetDerivationSchemeSettings(walletId);
@ -223,25 +225,25 @@ namespace BTCPayServer.Controllers
// We can't filter at the database level if we need to apply label filter
var preFiltering = string.IsNullOrEmpty(labelFilter);
var transactions = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, preFiltering ? skip : null, preFiltering ? count : null);
var walletTransactionsInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, transactions.Select(t => t.TransactionId.ToString()).ToArray());
var model = new ListTransactionsViewModel { Skip = skip, Count = count };
model.Labels.AddRange(
(await WalletRepository.GetWalletLabels(walletId))
.Select(c => (c.Label, c.Color, ColorPalette.Default.TextColor(c.Color))));
IList<TransactionHistoryLine>? transactions = null;
Dictionary<string, WalletTransactionInfo>? walletTransactionsInfo = null;
if (loadTransactions)
{
transactions = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, preFiltering ? skip : null, preFiltering ? count : null, cancellationToken: cancellationToken);
walletTransactionsInfo = await WalletRepository.GetWalletTransactionsInfo(walletId, transactions.Select(t => t.TransactionId.ToString()).ToArray());
}
if (labelFilter != null)
{
model.PaginationQuery = new Dictionary<string, object> { { "labelFilter", labelFilter } };
}
if (transactions == null)
if (transactions == null || walletTransactionsInfo is null)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message =
"There was an error retrieving the transactions list. Is NBXplorer configured correctly?"
});
model.Transactions = new List<ListTransactionsViewModel.TransactionViewModel>();
}
else
@ -1311,7 +1313,7 @@ namespace BTCPayServer.Controllers
[HttpGet("{walletId}/export")]
public async Task<IActionResult> Export(
[ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId,
string format, string? labelFilter = null)
string format, string? labelFilter = null, CancellationToken cancellationToken = default)
{
var paymentMethod = GetDerivationSchemeSettings(walletId);
if (paymentMethod == null)
@ -1319,7 +1321,7 @@ namespace BTCPayServer.Controllers
var wallet = _walletProvider.GetWallet(paymentMethod.Network);
var walletTransactionsInfoAsync = WalletRepository.GetWalletTransactionsInfo(walletId, (string[]?)null);
var input = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation);
var input = await wallet.FetchTransactionHistory(paymentMethod.AccountDerivation, cancellationToken: cancellationToken);
var walletTransactionsInfo = await walletTransactionsInfoAsync;
var export = new TransactionsExport(wallet, walletTransactionsInfo);
var res = export.Process(input, format);

@ -303,7 +303,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
bip21.Add(newUri.Uri.ToString());
break;
case AddressClaimDestination addressClaimDestination:
var bip21New = network.GenerateBIP21(addressClaimDestination.Address.ToString(), new Money(blob.CryptoAmount.Value, MoneyUnit.BTC));
var bip21New = network.GenerateBIP21(addressClaimDestination.Address.ToString(), blob.CryptoAmount.Value);
bip21New.QueryParams.Add("payout", payout.Id);
bip21.Add(bip21New.ToString());
break;

@ -6,5 +6,6 @@ namespace BTCPayServer.Data
{
public string? Id { get; }
decimal? Amount { get; }
bool IsExplicitAmountMinimum => false;
}
}

@ -23,5 +23,6 @@ namespace BTCPayServer.Data.Payouts.LightningLike
public uint256 PaymentHash { get; }
public string Id => PaymentHash.ToString();
public decimal? Amount { get; }
public bool IsExplicitAmountMinimum => true;
}
}

@ -264,7 +264,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
PaymentMethodId pmi, CancellationToken cancellationToken)
{
var boltAmount = bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC);
if (boltAmount != payoutBlob.CryptoAmount)
if (boltAmount > payoutBlob.CryptoAmount)
{
payoutData.State = PayoutState.Cancelled;
@ -277,15 +277,26 @@ namespace BTCPayServer.Data.Payouts.LightningLike
};
}
if (bolt11PaymentRequest.ExpiryDate < DateTimeOffset.Now)
{
payoutData.State = PayoutState.Cancelled;
return new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Message = $"The BOLT11 invoice expiry date ({bolt11PaymentRequest.ExpiryDate}) has expired",
Destination = payoutBlob.Destination
};
}
var proofBlob = new PayoutLightningBlob() { PaymentHash = bolt11PaymentRequest.PaymentHash.ToString() };
try
{
var result = await lightningClient.Pay(bolt11PaymentRequest.ToString(),
new PayInvoiceParams()
{
Amount = bolt11PaymentRequest.MinimumAmount == LightMoney.Zero
? new LightMoney((decimal)payoutBlob.CryptoAmount, LightMoneyUnit.BTC)
: null
// CLN does not support explicit amount param if it is the same as the invoice amount
Amount = payoutBlob.CryptoAmount == bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC)? null: new LightMoney((decimal)payoutBlob.CryptoAmount, LightMoneyUnit.BTC)
}, cancellationToken);
string message = null;
if (result.Result == PayResult.Ok)

@ -1,5 +1,7 @@
using System.Collections.Generic;
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data
{
@ -12,5 +14,10 @@ namespace BTCPayServer.Data
public int MinimumConfirmation { get; set; } = 1;
public string Destination { get; set; }
public int Revision { get; set; }
[JsonExtensionData]
public Dictionary<string, JToken> AdditionalData { get; set; } = new();
public JObject Metadata { get; set; }
}
}

@ -35,7 +35,9 @@ namespace BTCPayServer.Data
public static PayoutBlob GetBlob(this PayoutData data, BTCPayNetworkJsonSerializerSettings serializers)
{
return JsonConvert.DeserializeObject<PayoutBlob>(Encoding.UTF8.GetString(data.Blob), serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode));
var result = JsonConvert.DeserializeObject<PayoutBlob>(Encoding.UTF8.GetString(data.Blob), serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode));
result.Metadata ??= new JObject();
return result;
}
public static void SetBlob(this PayoutData data, PayoutBlob blob, BTCPayNetworkJsonSerializerSettings serializers)
{

@ -199,7 +199,9 @@ namespace BTCPayServer.Data
{ "GTQ", "bitpay" },
{ "COP", "yadio" },
{ "JPY", "bitbank" },
{ "TRY", "btcturk" }
{ "TRY", "btcturk" },
{ "UGX", "exchangeratehost"},
{ "RSD", "bitpay"}
};
public string GetRecommendedExchange() =>
@ -235,6 +237,12 @@ namespace BTCPayServer.Data
[DefaultValue(true)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public bool CelebratePayment { get; set; } = true;
[DefaultValue(true)]
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
public bool PlaySoundOnPayment { get; set; } = false;
public string SoundFileId { get; set; }
public IPaymentFilter GetExcludedPaymentMethods()
{

@ -22,6 +22,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Security;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Reporting;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@ -30,6 +31,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.Payment;
using NBitpayClient;
using NBXplorer.Models;
using Newtonsoft.Json;
using InvoiceCryptoInfo = BTCPayServer.Services.Invoices.InvoiceCryptoInfo;
@ -38,6 +40,15 @@ namespace BTCPayServer
{
public static class Extensions
{
public static DateTimeOffset TruncateMilliSeconds(this DateTimeOffset dt) => new (dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, 0, dt.Offset);
public static decimal? GetDue(this InvoiceCryptoInfo invoiceCryptoInfo)
{
if (invoiceCryptoInfo is null)
return null;
if (decimal.TryParse(invoiceCryptoInfo.Due, NumberStyles.Any, CultureInfo.InvariantCulture, out var v))
return v;
return null;
}
public static Task<BufferizedFormFile> Bufferize(this IFormFile formFile)
{
return BufferizedFormFile.Bufferize(formFile);
@ -105,16 +116,31 @@ namespace BTCPayServer
public static decimal RoundUp(decimal value, int precision)
{
for (int i = 0; i < precision; i++)
try
{
value = value * 10m;
for (int i = 0; i < precision; i++)
{
value = value * 10m;
}
value = Math.Ceiling(value);
for (int i = 0; i < precision; i++)
{
value = value / 10m;
}
return value;
}
value = Math.Ceiling(value);
for (int i = 0; i < precision; i++)
catch (OverflowException)
{
value = value / 10m;
return value;
}
return value;
}
public static IServiceCollection AddReportProvider<T>(this IServiceCollection services)
where T : ReportProvider
{
services.AddSingleton<T>();
services.AddSingleton<ReportProvider, T>();
return services;
}
public static IServiceCollection AddScheduledTask<T>(this IServiceCollection services, TimeSpan every)
@ -375,20 +401,6 @@ namespace BTCPayServer
return controller.View("PostRedirect", redirectVm);
}
public static string ToSql<TEntity>(this IQueryable<TEntity> query) where TEntity : class
{
var enumerator = query.Provider.Execute<IEnumerable<TEntity>>(query.Expression).GetEnumerator();
var relationalCommandCache = enumerator.Private("_relationalCommandCache");
var selectExpression = relationalCommandCache.Private<Microsoft.EntityFrameworkCore.Query.SqlExpressions.SelectExpression>("_selectExpression");
var factory = relationalCommandCache.Private<Microsoft.EntityFrameworkCore.Query.IQuerySqlGeneratorFactory>("_querySqlGeneratorFactory");
var sqlGenerator = factory.Create();
var command = sqlGenerator.GetCommand(selectExpression);
string sql = command.CommandText;
return sql;
}
public static BTCPayNetworkProvider ConfigureNetworkProvider(this IConfiguration configuration, Logs logs)
{
var _networkType = DefaultConfiguration.GetNetworkType(configuration);

@ -1,4 +1,5 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Client;
using BTCPayServer.Data;
@ -43,5 +44,13 @@ namespace BTCPayServer
.FirstOrDefault(p => p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike && p.PaymentId.CryptoCode == cryptoCode);
return paymentMethod;
}
public static IEnumerable<DerivationSchemeSettings> GetDerivationSchemeSettings(this StoreData store, BTCPayNetworkProvider networkProvider)
{
var paymentMethod = store
.GetSupportedPaymentMethods(networkProvider)
.OfType<DerivationSchemeSettings>()
.Where(p => p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike);
return paymentMethod;
}
}
}

@ -10,6 +10,7 @@ namespace BTCPayServer
{
// Thanks to https://www.garykessler.net/software/FileSigs_20220731.zip
const string pictureSigs =
"JPEG2000 image files,00 00 00 0C 6A 50 20 20,JP2,Picture,0,(null)\n" +
"Bitmap image,42 4D,BMP|DIB,Picture,0,(null)\n" +
@ -19,19 +20,30 @@ namespace BTCPayServer
"JPEG-EXIF-SPIFF images,FF D8 FF,JFIF|JPE|JPEG|JPG,Picture,0,FF D9\n" +
"SVG images, 3C 73 76 67,SVG,Picture,0,(null)\n" +
"Google WebP image file, 52 49 46 46 XX XX XX XX 57 45 42 50,WEBP,Picture,0,(null)\n" +
"AVIF image file, XX XX XX XX 66 74 79 70,AVIF,Picture,0,(null)\n";
"AVIF image file, XX XX XX XX 66 74 79 70,AVIF,Picture,0,(null)\n" +
"MP3 audio file,49 44 33,MP3,Multimedia,0,(null)\n" +
"MP3 audio file,FF,MP3,Multimedia,0,(null)\n" +
"RIFF Windows Audio,57 41 56 45 66 6D 74 20,WAV,Multimedia,8,(null)\n" +
"Free Lossless Audio Codec file,66 4C 61 43 00 00 00 22,FLAC,Multimedia,0,(null)\n" +
"MPEG-4 AAC audio,FF F1,AAC,Audio,0,(null)\n" +
"Ogg Vorbis Codec compressed file,4F 67 67 53,OGA|OGG|OGV|OGX,Multimedia,0,(null)\n" +
"Apple Lossless Audio Codec file,66 74 79 70 4D 34 41 20,M4A,Multimedia,4,(null)\n" +
"WebM/WebA,66 74 79 70 4D 34 41 20,M4A,Multimedia,4,(null)\n" +
"WebM/WEBA video file,1A 45 DF A3,WEBM|WEBA,Multimedia,0,(null)\n" +
"Resource Interchange File Format,52 49 46 46,AVI|CDA|QCP|RMI|WAV|WEBP,Multimedia,0,(null)\n";
readonly static (int[] Header, int[]? Trailer, string[] Extensions)[] headerTrailers;
readonly static (int[] Header, int[]? Trailer, string Type, string[] Extensions)[] headerTrailers;
static FileTypeDetector()
{
var lines = pictureSigs.Split('\n', StringSplitOptions.RemoveEmptyEntries);
headerTrailers = new (int[] Header, int[]? Trailer, string[] Extensions)[lines.Length];
headerTrailers = new (int[] Header, int[]? Trailer, string Type, string[] Extensions)[lines.Length];
for (int i = 0; i < lines.Length; i++)
{
var cells = lines[i].Split(',');
headerTrailers[i] = (
DecodeData(cells[1]),
cells[^1] == "(null)" ? null : DecodeData(cells[^1]),
cells[3],
cells[2].Split('|').Select(p => $".{p}").ToArray()
);
}
@ -51,11 +63,21 @@ namespace BTCPayServer
}
return res;
}
public static bool IsPicture(byte[] bytes, string? filename)
{
return IsFileType(bytes, filename, new[] { "Picture" });
}
public static bool IsAudio(byte[] bytes, string? filename)
{
return IsFileType(bytes, filename, new[] { "Multimedia", "Audio" });
}
static bool IsFileType(byte[] bytes, string? filename, string[] types)
{
for (int i = 0; i < headerTrailers.Length; i++)
{
if (!types.Contains(headerTrailers[i].Type))
goto next;
if (headerTrailers[i].Header is int[] header)
{
if (header.Length > bytes.Length)
@ -80,7 +102,7 @@ namespace BTCPayServer
if (filename is not null)
{
if (!headerTrailers[i].Extensions.Any(ext => filename.Length > ext.Length && filename.EndsWith(ext, StringComparison.OrdinalIgnoreCase)))
return false;
goto next;
}
return true;
next:

@ -1,5 +1,6 @@
using System.Collections.Generic;
using BTCPayServer.Abstractions.Form;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Forms;
@ -10,7 +11,7 @@ public class FieldValueMirror : IFormComponentProvider
{
if (form.GetFieldByFullName(field.Value) is null)
{
field.ValidationErrors = new List<string> { $"{field.Name} requires {field.Value} to be present" };
field.ValidationErrors = new List<string> {$"{field.Name} requires {field.Value} to be present"};
}
}
@ -21,6 +22,13 @@ public class FieldValueMirror : IFormComponentProvider
public string GetValue(Form form, Field field)
{
return form.GetFieldByFullName(field.Value)?.Value;
var rawValue = form.GetFieldByFullName(field.Value)?.Value;
if (rawValue is not null && field.AdditionalData?.TryGetValue("valuemap", out var valueMap) is true &&
valueMap is JObject map && map.TryGetValue(rawValue, out var mappedValue))
{
return mappedValue.Value<string>();
}
return rawValue;
}
}

@ -151,11 +151,32 @@ public class FormDataService
public CreateInvoiceRequest GenerateInvoiceParametersFromForm(Form form)
{
var amt = GetValue(form, $"{InvoiceParameterPrefix}amount");
var amtRaw = GetValue(form, $"{InvoiceParameterPrefix}amount");
var amt = string.IsNullOrEmpty(amtRaw) ? (decimal?) null : decimal.Parse(amtRaw, CultureInfo.InvariantCulture);
var adjustmentAmount = 0m;
foreach (var adjustmentField in form.GetAllFields().Where(f => f.FullName.StartsWith($"{InvoiceParameterPrefix}amount_adjustment")))
{
if (!decimal.TryParse(GetValue(form, adjustmentField.Field), out var adjustment))
{
continue;
}
adjustmentAmount += adjustment;
}
if (amt is null && adjustmentAmount > 0)
{
amt = adjustmentAmount;
}
else if(amt is not null)
{
amt += adjustmentAmount;
amt = Math.Max(0, amt!.Value);
}
return new CreateInvoiceRequest
{
Currency = GetValue(form, $"{InvoiceParameterPrefix}currency"),
Amount = string.IsNullOrEmpty(amt) ? null : decimal.Parse(amt, CultureInfo.InvariantCulture),
Amount = amt,
Metadata = GetValues(form),
};
}

@ -205,7 +205,10 @@ public class UIFormsController : Controller
var request = _formDataService.GenerateInvoiceParametersFromForm(form);
var inv = await invoiceController.CreateInvoiceCoreRaw(request, store, Request.GetAbsoluteRoot());
if (inv.Price == 0 && inv.Type == InvoiceType.Standard && inv.ReceiptOptions?.Enabled is not false)
{
return RedirectToAction("InvoiceReceipt", "UIInvoice", new { invoiceId = inv.Id });
}
return RedirectToAction("Checkout", "UIInvoice", new { invoiceId = inv.Id });
}
}

@ -7,6 +7,7 @@ using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Events;
using BTCPayServer.Logging;
using BTCPayServer.Payments;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
@ -83,31 +84,13 @@ namespace BTCPayServer.HostedServices
if (invoice.ExceptionStatus == InvoiceExceptionStatus.PaidPartial)
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.ExpiredPaidPartial) { PaidPartial = paidPartial });
}
var allPaymentMethods = invoice.GetPaymentMethods();
var paymentMethod = GetNearestClearedPayment(allPaymentMethods, out var accounting);
if (allPaymentMethods.Any() && paymentMethod == null)
return;
if (accounting is null && invoice.Price is 0m)
{
accounting = new PaymentMethodAccounting()
{
Due = Money.Zero,
Paid = Money.Zero,
CryptoPaid = Money.Zero,
DueUncapped = Money.Zero,
NetworkFee = Money.Zero,
TotalDue = Money.Zero,
TxCount = 0,
TxRequired = 0,
MinimumTotalDue = Money.Zero,
NetworkFeeAlreadyPaid = Money.Zero
};
}
var hasPayment = invoice.GetPayments(true).Any();
if (invoice.Status == InvoiceStatusLegacy.New || invoice.Status == InvoiceStatusLegacy.Expired)
{
var isPaid = invoice.IsUnsetTopUp() ?
accounting.Paid > Money.Zero :
accounting.Paid >= accounting.MinimumTotalDue;
hasPayment :
!invoice.IsUnderPaid;
if (isPaid)
{
if (invoice.Status == InvoiceStatusLegacy.New)
@ -117,13 +100,15 @@ namespace BTCPayServer.HostedServices
if (invoice.IsUnsetTopUp())
{
invoice.ExceptionStatus = InvoiceExceptionStatus.None;
invoice.Price = (accounting.Paid - accounting.NetworkFeeAlreadyPaid).ToDecimal(MoneyUnit.BTC) * paymentMethod.Rate;
accounting = paymentMethod.Calculate();
// We know there is at least one payment because hasPayment is true
var payment = invoice.GetPayments(true).First();
invoice.Price = payment.InvoicePaidAmount.Net;
invoice.UpdateTotals();
context.BlobUpdated();
}
else
{
invoice.ExceptionStatus = accounting.Paid > accounting.TotalDue ? InvoiceExceptionStatus.PaidOver : InvoiceExceptionStatus.None;
invoice.ExceptionStatus = invoice.IsOverPaid ? InvoiceExceptionStatus.PaidOver : InvoiceExceptionStatus.None;
}
context.MarkDirty();
}
@ -135,7 +120,7 @@ namespace BTCPayServer.HostedServices
}
}
if (accounting.Paid < accounting.MinimumTotalDue && invoice.GetPayments(true).Count != 0 && invoice.ExceptionStatus != InvoiceExceptionStatus.PaidPartial)
if (hasPayment && invoice.IsUnderPaid && invoice.ExceptionStatus != InvoiceExceptionStatus.PaidPartial)
{
invoice.ExceptionStatus = InvoiceExceptionStatus.PaidPartial;
context.MarkDirty();
@ -145,43 +130,43 @@ namespace BTCPayServer.HostedServices
// Just make sure RBF did not cancelled a payment
if (invoice.Status == InvoiceStatusLegacy.Paid)
{
if (accounting.MinimumTotalDue <= accounting.Paid && accounting.Paid <= accounting.TotalDue && invoice.ExceptionStatus == InvoiceExceptionStatus.PaidOver)
if (!invoice.IsUnderPaid && !invoice.IsOverPaid && invoice.ExceptionStatus == InvoiceExceptionStatus.PaidOver)
{
invoice.ExceptionStatus = InvoiceExceptionStatus.None;
context.MarkDirty();
}
if (accounting.Paid > accounting.TotalDue && invoice.ExceptionStatus != InvoiceExceptionStatus.PaidOver)
if (invoice.IsOverPaid && invoice.ExceptionStatus != InvoiceExceptionStatus.PaidOver)
{
invoice.ExceptionStatus = InvoiceExceptionStatus.PaidOver;
context.MarkDirty();
}
if (accounting.Paid < accounting.MinimumTotalDue)
if (invoice.IsUnderPaid)
{
invoice.Status = InvoiceStatusLegacy.New;
invoice.ExceptionStatus = accounting.Paid == Money.Zero ? InvoiceExceptionStatus.None : InvoiceExceptionStatus.PaidPartial;
invoice.ExceptionStatus = hasPayment ? InvoiceExceptionStatus.PaidPartial : InvoiceExceptionStatus.None;
context.MarkDirty();
}
}
if (invoice.Status == InvoiceStatusLegacy.Paid)
{
var confirmedAccounting =
paymentMethod?.Calculate(p => p.GetCryptoPaymentData().PaymentConfirmed(p, invoice.SpeedPolicy)) ??
accounting;
var unconfPayments = invoice.GetPayments(true).Where(p => !p.GetCryptoPaymentData().PaymentConfirmed(p, invoice.SpeedPolicy)).ToList();
var unconfirmedPaid = unconfPayments.Select(p => p.InvoicePaidAmount.Net).Sum();
var minimumDue = invoice.MinimumNetDue + unconfirmedPaid;
if (// Is after the monitoring deadline
(invoice.MonitoringExpiration < DateTimeOffset.UtcNow)
&&
// And not enough amount confirmed
(confirmedAccounting.Paid < accounting.MinimumTotalDue))
(minimumDue > 0.0m))
{
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.FailedToConfirm));
invoice.Status = InvoiceStatusLegacy.Invalid;
context.MarkDirty();
}
else if (confirmedAccounting.Paid >= accounting.MinimumTotalDue)
else if (minimumDue <= 0.0m)
{
invoice.Status = InvoiceStatusLegacy.Confirmed;
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.Confirmed));
@ -191,9 +176,11 @@ namespace BTCPayServer.HostedServices
if (invoice.Status == InvoiceStatusLegacy.Confirmed)
{
var completedAccounting = paymentMethod?.Calculate(p => p.GetCryptoPaymentData().PaymentCompleted(p)) ??
accounting;
if (completedAccounting.Paid >= accounting.MinimumTotalDue)
var unconfPayments = invoice.GetPayments(true).Where(p => !p.GetCryptoPaymentData().PaymentCompleted(p)).ToList();
var unconfirmedPaid = unconfPayments.Select(p => p.InvoicePaidAmount.Net).Sum();
var minimumDue = invoice.MinimumNetDue + unconfirmedPaid;
if (minimumDue <= 0.0m)
{
context.Events.Add(new InvoiceEvent(invoice, InvoiceEvent.Completed));
invoice.Status = InvoiceStatusLegacy.Complete;
@ -203,25 +190,6 @@ namespace BTCPayServer.HostedServices
}
public static PaymentMethod GetNearestClearedPayment(PaymentMethodDictionary allPaymentMethods, out PaymentMethodAccounting accounting)
{
PaymentMethod result = null;
accounting = null;
decimal nearestToZero = 0.0m;
foreach (var paymentMethod in allPaymentMethods)
{
var currentAccounting = paymentMethod.Calculate();
var distanceFromZero = Math.Abs(currentAccounting.DueUncapped.ToDecimal(MoneyUnit.BTC));
if (result == null || distanceFromZero < nearestToZero)
{
result = paymentMethod;
nearestToZero = distanceFromZero;
accounting = currentAccounting;
}
}
return result;
}
private void Watch(string invoiceId)
{
ArgumentNullException.ThrowIfNull(invoiceId);
@ -380,7 +348,7 @@ namespace BTCPayServer.HostedServices
if ((onChainPaymentData.ConfirmationCount < network.MaxTrackedConfirmation && payment.Accounted)
&& (onChainPaymentData.Legacy || invoice.MonitoringExpiration < DateTimeOffset.UtcNow))
{
var client = _explorerClientProvider.GetExplorerClient(payment.GetCryptoCode());
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;

@ -46,6 +46,8 @@ namespace BTCPayServer.HostedServices
public class PullPaymentHostedService : BaseAsyncService
{
private readonly string[] _lnurlSupportedCurrencies = { "BTC", "SATS" };
public class CancelRequest
{
public CancelRequest(string pullPaymentId)
@ -337,6 +339,14 @@ namespace BTCPayServer.HostedServices
}
}
public bool SupportsLNURL(PullPaymentBlob blob)
{
var pms = blob.SupportedPaymentMethods.FirstOrDefault(id =>
id.PaymentType == LightningPaymentType.Instance &&
_networkProvider.DefaultNetwork.CryptoCode == id.CryptoCode);
return pms is not null && _lnurlSupportedCurrencies.Contains(blob.Currency);
}
public Task<RateResult> GetRate(PayoutData payout, string explicitRateRule, CancellationToken cancellationToken)
{
var ppBlob = payout.PullPaymentData?.GetBlob();
@ -379,7 +389,7 @@ namespace BTCPayServer.HostedServices
{
try
{
using var ctx = _dbContextFactory.CreateContext();
await using var ctx = _dbContextFactory.CreateContext();
var payout = await ctx.Payouts.Include(p => p.PullPaymentData).Where(p => p.Id == req.PayoutId)
.FirstOrDefaultAsync();
if (payout is null)
@ -430,6 +440,7 @@ namespace BTCPayServer.HostedServices
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.SaveChangesAsync();
_eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Approved, payout));
req.Completion.SetResult(new PayoutApproval.ApprovalResult(PayoutApproval.Result.Ok, payoutBlob.CryptoAmount));
}
catch (Exception ex)
@ -586,7 +597,8 @@ namespace BTCPayServer.HostedServices
var payoutBlob = new PayoutBlob()
{
Amount = claimed,
Destination = req.ClaimRequest.Destination.ToString()
Destination = req.ClaimRequest.Destination.ToString(),
Metadata = req.ClaimRequest.Metadata?? new JObject(),
};
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.Payouts.AddAsync(payout);
@ -594,6 +606,8 @@ namespace BTCPayServer.HostedServices
{
await payoutHandler.TrackClaim(req.ClaimRequest, payout);
await ctx.SaveChangesAsync();
var response = new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Ok, payout);
_eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Created, payout));
if (req.ClaimRequest.PreApprove.GetValueOrDefault(ppBlob?.AutoApproveClaims is true))
{
payout.StoreData = await ctx.Stores.FindAsync(payout.StoreDataId);
@ -618,7 +632,7 @@ namespace BTCPayServer.HostedServices
}
}
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Ok, payout));
req.Completion.TrySetResult(response);
await _notificationSender.SendNotification(new StoreScope(payout.StoreDataId),
new PayoutNotification()
{
@ -820,6 +834,31 @@ namespace BTCPayServer.HostedServices
public class ClaimRequest
{
public static (string error, decimal? amount) IsPayoutAmountOk(IClaimDestination destination, decimal? amount, string payoutCurrency = null, string ppCurrency = null)
{
return amount switch
{
null when destination.Amount is null && ppCurrency is null => ("Amount is not specified in destination or payout request", null),
null when destination.Amount is null => (null, null),
null when destination.Amount != null => (null,destination.Amount),
not null when destination.Amount is null => (null,amount),
not null when destination.Amount != null && amount != destination.Amount &&
destination.IsExplicitAmountMinimum &&
payoutCurrency == "BTC" && ppCurrency == "SATS" &&
new Money(amount.Value, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) < destination.Amount =>
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null),
not null when destination.Amount != null && amount != destination.Amount &&
destination.IsExplicitAmountMinimum &&
!(payoutCurrency == "BTC" && ppCurrency == "SATS") &&
amount < destination.Amount =>
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null),
not null when destination.Amount != null && amount != destination.Amount &&
!destination.IsExplicitAmountMinimum =>
($"Amount is implied in destination ({destination.Amount}) that does not match the payout amount provided {amount})", null),
_ => (null, amount)
};
}
public static string GetErrorMessage(ClaimResult result)
{
switch (result)
@ -877,5 +916,16 @@ namespace BTCPayServer.HostedServices
public IClaimDestination Destination { get; set; }
public string StoreId { get; set; }
public bool? PreApprove { get; set; }
public JObject Metadata { get; set; }
}
public record PayoutEvent(PayoutEvent.PayoutEventType Type,PayoutData Payout)
{
public enum PayoutEventType
{
Created,
Approved
}
}
}

@ -103,7 +103,7 @@ namespace BTCPayServer.HostedServices
invoiceEvent.Payment.GetPaymentMethodId()?.PaymentType == BitcoinPaymentType.Instance &&
invoiceEvent.Payment.GetCryptoPaymentData() is BitcoinLikePaymentData bitcoinLikePaymentData:
{
var walletId = new WalletId(invoiceEvent.Invoice.StoreId, invoiceEvent.Payment.GetCryptoCode());
var walletId = new WalletId(invoiceEvent.Invoice.StoreId, invoiceEvent.Payment.Currency);
var transactionId = bitcoinLikePaymentData.Outpoint.Hash;
var labels = new List<Attachment>
{

@ -65,6 +65,7 @@ using NBXplorer.DerivationStrategy;
using Newtonsoft.Json;
using NicolasDorier.RateLimits;
using Serilog;
using BTCPayServer.Services.Reporting;
#if ALTCOINS
using BTCPayServer.Services.Altcoins.Monero;
using BTCPayServer.Services.Altcoins.Zcash;
@ -278,7 +279,8 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<AppService>();
services.AddTransient<PluginService>();
services.AddSingleton<IPluginHookService, PluginHookService>();
services.AddSingleton<PluginHookService>();
services.AddSingleton<IPluginHookService, PluginHookService>(provider => provider.GetService<PluginHookService>());
services.TryAddTransient<Safe>();
services.TryAddTransient<DisplayFormatter>();
services.TryAddSingleton<Ganss.XSS.HtmlSanitizer>(o =>
@ -324,6 +326,7 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<LightningConfigurationProvider>();
services.TryAddSingleton<LanguageService>();
services.TryAddSingleton<ReportService>();
services.TryAddSingleton<NBXplorerDashboard>();
services.AddSingleton<ISyncSummaryProvider, NBXSyncSummaryProvider>();
services.TryAddSingleton<StoreRepository>();
@ -353,6 +356,10 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService, PeriodicTaskLauncherHostedService>();
services.AddScheduledTask<CleanupWebhookDeliveriesTask>(TimeSpan.FromHours(6.0));
services.AddReportProvider<PaymentsReportProvider>();
services.AddReportProvider<OnChainWalletReportProvider>();
services.AddReportProvider<ProductsReportProvider>();
services.AddHttpClient(WebhookSender.OnionNamedClient)
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
services.AddHttpClient(WebhookSender.LoopbackNamedClient)
@ -520,6 +527,8 @@ namespace BTCPayServer.Hosting
services.AddRateProvider<BitflyerRateProvider>();
services.AddRateProvider<YadioRateProvider>();
services.AddRateProvider<BtcTurkRateProvider>();
services.AddRateProvider<FreeCurrencyRatesRateProvider>();
services.AddRateProvider<ExchangeRateHostRateProvider>();
// Broken
// Providers.Add("argoneum", new ArgoneumRateProvider(_httpClientFactory?.CreateClient("EXCHANGE_ARGONEUM")));

@ -341,9 +341,13 @@ namespace BTCPayServer.Hosting
{
var items = new List<ViewPointOfSaleViewModel.Item>();
var stream = new YamlStream();
if (string.IsNullOrEmpty(yaml))
return items.ToArray();
stream.Load(new StringReader(yaml));
var root = stream.Documents.First().RootNode as YamlMappingNode;
if(stream.Documents.FirstOrDefault()?.RootNode is not YamlMappingNode root)
return items.ToArray();
foreach (var posItem in root.Children)
{
var trimmedKey = ((YamlScalarNode)posItem.Key).Value?.Trim();
@ -640,8 +644,6 @@ WHERE cte.""Id""=p.""Id""
await using var ctx = _DBContextFactory.CreateContext();
foreach (var app in await ctx.Apps.Include(data => data.StoreData).AsQueryable().ToArrayAsync())
{
ViewPointOfSaleViewModel.Item[] items;
string newTemplate;
switch (app.AppType)
{
case CrowdfundAppType.AppType:

@ -41,6 +41,5 @@ namespace BTCPayServer.Models.InvoicingModels
public string Id { get; set; }
public string AppName { get; set; }
public string AppType { get; set; }
public string AppOrderId { get; set; }
}
}

@ -27,6 +27,9 @@ namespace BTCPayServer.Models.InvoicingModels
public string CustomLogoLink { get; set; }
public string CssFileId { get; set; }
public string LogoFileId { get; set; }
public string PaymentSoundUrl { get; set; }
public string NfcReadSoundUrl { get; set; }
public string ErrorSoundUrl { get; set; }
public string BrandColor { get; set; }
public string HtmlTitle { get; set; }
public string DefaultLang { get; set; }

@ -18,6 +18,9 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
{
public List<ViewPaymentRequestViewModel> Items { get; set; }
public override int CurrentPageCount => Items.Count;
public SearchString Search { get; set; }
public string SearchText { get; set; }
}
public class UpdatePaymentRequestViewModel

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Models.StoreReportsViewModels;
public class StoreReportsViewModel
{
public string InvoiceTemplateUrl { get; set; }
public Dictionary<string,string> ExplorerTemplateUrls { get; set; }
public StoreReportRequest Request { get; set; }
public List<string> AvailableViews { get; set; }
public StoreReportResponse Result { get; set; }
}

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Newtonsoft.Json.Linq;
@ -44,6 +45,9 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Celebrate payment with confetti")]
public bool CelebratePayment { get; set; }
[Display(Name = "Enable sounds on checkout page")]
public bool PlaySoundOnPayment { get; set; }
[Display(Name = "Requires a refund email")]
public bool RequiresRefundEmail { get; set; }
@ -61,9 +65,14 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Link to a custom CSS stylesheet")]
public string CustomCSS { get; set; }
[Display(Name = "Link to a custom logo")]
public string CustomLogo { get; set; }
[Display(Name = "Custom sound file for successful payment")]
public IFormFile SoundFile { get; set; }
public string SoundFileId { get; set; }
[Display(Name = "Custom HTML title to display on Checkout page")]
public string HtmlTitle { get; set; }

@ -96,6 +96,7 @@ namespace BTCPayServer.Models
public DateTimeOffset StartDate { get; set; }
public DateTime LastRefreshed { get; set; }
public CurrencyData CurrencyData { get; set; }
public Uri LnurlEndpoint { get; set; }
public bool Archived { get; set; }
public bool AutoApprove { get; set; }

@ -26,7 +26,8 @@ namespace BTCPayServer.Models.WalletViewModels
public bool Selected { get; set; }
public DateTimeOffset Date { get; set; }
public string PullPaymentId { get; set; }
public string PullPaymentName { get; set; }
public string Source { get; set; }
public string SourceLink { get; set; }
public string Destination { get; set; }
public string Amount { get; set; }
public string ProofLink { get; set; }

@ -167,7 +167,7 @@ namespace BTCPayServer.PaymentRequest
new object[]
{
data.GetValue(),
invoiceEvent.Payment.GetCryptoCode(),
invoiceEvent.Payment.Currency,
invoiceEvent.Payment.GetPaymentMethodId()?.PaymentType?.ToString()
}, cancellationToken);
}

@ -123,18 +123,14 @@ namespace BTCPayServer.PaymentRequest
string txId = paymentData.GetPaymentId();
string link = GetTransactionLink(paymentMethodId, txId);
var paymentMethod = entity.GetPaymentMethod(paymentMethodId);
var amount = paymentData.GetValue();
var rate = paymentMethod.Rate;
var paid = (amount - paymentEntity.NetworkFee) * rate;
return new ViewPaymentRequestViewModel.PaymentRequestInvoicePayment
{
Amount = amount,
Paid = paid,
Amount = paymentEntity.PaidAmount.Gross,
Paid = paymentEntity.InvoicePaidAmount.Net,
ReceivedDate = paymentEntity.ReceivedTime.DateTime,
PaidFormatted = _displayFormatter.Currency(paid, blob.Currency, DisplayFormatter.CurrencyFormat.Symbol),
RateFormatted = _displayFormatter.Currency(rate, blob.Currency, DisplayFormatter.CurrencyFormat.Symbol),
PaidFormatted = _displayFormatter.Currency(paymentEntity.InvoicePaidAmount.Net, blob.Currency, DisplayFormatter.CurrencyFormat.Symbol),
RateFormatted = _displayFormatter.Currency(paymentEntity.Rate, blob.Currency, DisplayFormatter.CurrencyFormat.Symbol),
PaymentMethod = paymentMethodId.ToPrettyString(),
Link = link,
Id = txId,

@ -211,7 +211,7 @@ namespace BTCPayServer.Payments.Bitcoin
new Key().GetScriptPubKey(supportedPaymentMethod.AccountDerivation.ScriptPubKeyType());
var dust = txOut.GetDustThreshold();
var amount = paymentMethod.Calculate().Due;
if (amount < dust)
if (amount < dust.ToDecimal(MoneyUnit.BTC))
throw new PaymentMethodUnavailableException("Amount below the dust threshold. For amounts of this size, it is recommended to enable an off-chain (Lightning) payment method");
}
if (preparePaymentObject is null)

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Payments.Lightning;
@ -28,7 +29,7 @@ namespace BTCPayServer.Payments
}
public override string GetPaymentLink(BTCPayNetworkBase network, InvoiceEntity invoice, IPaymentMethodDetails paymentMethodDetails,
Money cryptoInfoDue, string serverUri)
decimal cryptoInfoDue, string serverUri)
{
if (!paymentMethodDetails.Activated)
{
@ -74,7 +75,7 @@ namespace BTCPayServer.Payments
{
AdditionalData = new Dictionary<string, JToken>()
{
{"LNURLP", JToken.FromObject(GetPaymentLink(details.Network, invoice, details.GetPaymentMethodDetails(), invoiceCryptoInfo.Due,
{"LNURLP", JToken.FromObject(GetPaymentLink(details.Network, invoice, details.GetPaymentMethodDetails(), invoiceCryptoInfo.GetDue().Value,
serverUrl))}
}
};

@ -9,6 +9,7 @@ using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
using BTCPayServer.Lightning.LndHub;
using BTCPayServer.Logging;
using BTCPayServer.Models;
using BTCPayServer.Models.InvoicingModels;
@ -72,7 +73,7 @@ namespace BTCPayServer.Payments.Lightning
decimal due = Extensions.RoundUp(invoice.Price / paymentMethod.Rate, network.Divisibility);
try
{
due = paymentMethod.Calculate().Due.ToDecimal(MoneyUnit.BTC);
due = paymentMethod.Calculate().Due;
}
catch (Exception)
{
@ -122,11 +123,16 @@ namespace BTCPayServer.Payments.Lightning
{
if (!_Dashboard.IsFullySynched(network.CryptoCode, out var summary))
throw new PaymentMethodUnavailableException("Full node not available");
try
{
using var cts = new CancellationTokenSource(LightningTimeout);
var client = CreateLightningClient(supportedPaymentMethod, network);
// LNDhub-compatible implementations might not offer all of GetInfo data.
// Skip checks in those cases, see https://github.com/lnbits/lnbits/issues/1182
var isLndHub = client is LndHubLightningClient;
LightningNodeInformation info;
try
{
@ -136,6 +142,10 @@ namespace BTCPayServer.Payments.Lightning
{
throw new PaymentMethodUnavailableException("The lightning node did not reply in a timely manner");
}
catch (NotSupportedException) when (isLndHub)
{
return new NodeInfo[] {};
}
catch (Exception ex)
{
throw new PaymentMethodUnavailableException($"Error while connecting to the API: {ex.Message}" +
@ -146,9 +156,9 @@ namespace BTCPayServer.Payments.Lightning
var nodeInfo = preferOnion != null && info.NodeInfoList.Any(i => i.IsTor == preferOnion)
? info.NodeInfoList.Where(i => i.IsTor == preferOnion.Value).ToArray()
: info.NodeInfoList.Select(i => i).ToArray();
var blocksGap = summary.Status.ChainHeight - info.BlockHeight;
if (blocksGap > 10)
if (blocksGap > 10 && !(isLndHub && info.BlockHeight == 0))
{
throw new PaymentMethodUnavailableException($"The lightning node is not synched ({blocksGap} blocks left)");
}

@ -3,6 +3,7 @@ using System.Linq;
using BTCPayServer.Lightning;
using BTCPayServer.Services.Invoices;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Payments.Lightning
@ -10,7 +11,9 @@ namespace BTCPayServer.Payments.Lightning
public class LightningLikePaymentMethodDetails : IPaymentMethodDetails
{
public string BOLT11 { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
public uint256 PaymentHash { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
public uint256 Preimage { get; set; }
public string InvoiceId { get; set; }
public string NodeInfo { get; set; }

@ -200,7 +200,7 @@ namespace BTCPayServer.Payments.Lightning
if (inv.Name == InvoiceEvent.ReceivedPayment && inv.Invoice.Status == InvoiceStatusLegacy.New && inv.Invoice.ExceptionStatus == InvoiceExceptionStatus.PaidPartial)
{
var pm = inv.Invoice.GetPaymentMethods().First();
if (pm.Calculate().Due.GetValue(pm.Network as BTCPayNetwork) > 0m)
if (pm.Calculate().Due > 0m)
{
await CreateNewLNInvoiceForBTCPayInvoice(inv.Invoice);
}

@ -302,7 +302,7 @@ namespace BTCPayServer.Payments.PayJoin
var paymentDetails = paymentMethod?.GetPaymentMethodDetails() as Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod;
if (paymentMethod is null || paymentDetails is null || !paymentDetails.PayjoinEnabled)
continue;
due = paymentMethod.Calculate().TotalDue - output.Value;
due = Money.Coins(paymentMethod.Calculate().TotalDue) - output.Value;
if (due > Money.Zero)
{
break;

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