Compare commits

..

109 Commits

Author SHA1 Message Date
4062741ade Add payouts report 2023-09-13 14:42:30 +02:00
73a4ac599c Add Blazor server ()
* Add Blazor server

* Improve Blazor status UI

* Improve UX

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2023-09-13 13:13:15 +09:00
4aedf76f1f Dashboard: Paid invoices in the last 7 days ()
Adjust the prior number of transactions metric as discussed with @pavlenex. We now show the number of paid invoices instead of transactions, as this metric is more meaningful.

Closes .
2023-09-13 09:02:02 +09:00
2d38113c66 Remove legacy confusing export () 2023-09-12 16:33:37 +09:00
445e1b7bd9 NFC: Fix error display ()
Simple fix, the wrong variable was used. Fixes .
2023-09-12 13:48:19 +09:00
019ac7ae31 Checkout: Cheating improvements ()
Minor updates to the cheating options:
- Some browsers do not submit disabled fields, hence I made the amount field readonly in case of Lightning.
- Convert remaining amount when switching from onchain BTC to Lightning sats.
2023-09-12 13:48:01 +09:00
2b3b025bd8 Login: Re-add Remember Me button ()
Closes .
2023-09-12 12:16:37 +09:00
57bc90ad03 Archive stores and apps ()
* Add flags and migration

* Archive store

* Archive apps
2023-09-11 09:59:17 +09:00
089e16020e Update LND image version ()
See .
2023-09-10 10:07:44 +09:00
0c4f31794d Test fix: Update rate retrieval skipping parameters () 2023-09-09 09:46:09 +02:00
cdffe9b355 Bump LNURL 2023-09-07 10:02:32 +02:00
5a28cf9e87 Release new version of client 2023-09-06 08:21:46 +09:00
3b05de7f30 Fix: Crash caused by very old point of sales invoices () () 2023-09-05 15:32:49 +09:00
79b2f1652b Changelog and bump 2023-09-02 23:22:59 +09:00
b32e0e7cce Fix : Error on the MigrationStartupTask 2023-09-02 23:12:37 +09:00
1f9fbbee22 Update README ()
Co-authored-by: A. I. Oleynikov <self@oleynikov.ai>
2023-09-01 16:03:51 +02:00
8c9f325c9f Display wallet balance in default currency ()
* Display wallet balance in default currency

* Cleanups

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2023-09-01 15:29:41 +09:00
9bf1e35bf4 Use _blank link targt for payment scheme links ()
In addition to . Fixes .
2023-08-31 09:44:10 +09:00
32e830a1c5 Fix slow 'Fasts' tests 2023-08-28 09:40:44 +09:00
561bae071f Timeout CanGetRateCryptoCurrenciesByDefault 2023-08-26 21:13:55 +09:00
08b6942c59 Bump, changelog 2023-08-26 21:03:45 +09:00
4564f9a46c Small improvements ()
* BUmp LNURL

* Show app view link in nav when not enoguh permission to modify

* FIx permission misalignment on create pull payments

We have explicit permissions for pull payment creation, even allow them to be created through the invoices, but the create ui and cta were blocked behind  canmodify store permission.

* Make Ln address pass an invoiceId in the context to resolve breaking change
2023-08-26 20:50:07 +09:00
58a1c6d2c8 Parse POS string data for invoice details display ()
* Parse POS string data for invoice details display

Fixes .

* Improve POS data display
2023-08-26 20:48:48 +09:00
97acec340c fix lnaddress nav item permission 2023-08-24 16:31:49 +02:00
52790a6954 bump clightning 2023-08-24 20:59:15 +09:00
af6249a741 bump lightning lib 2023-08-24 20:59:15 +09:00
17064ab3c8 POS: Unify item display in editor () 2023-08-24 08:51:22 +02:00
1487bf4ff5 Unset link targt for payment scheme links ()
Potential fix for  — see the discussion in that issue for details.

This change should be non-invasive, I tested the links in regular as well as modal mode and they worked in Firefox, Brave and Chrome.
2023-08-24 13:37:27 +09:00
e8c0858558 POS: Fix alignment of items in static view ()
Items in static view weren't center aligned. This matches the classes in the cart view. Fixes .
2023-08-23 11:11:41 +02:00
56fa3fe8f2 Fix crash on /wallets/transactions with non zero skip parameter (Fix ) 2023-08-23 16:11:25 +09:00
583813883c Simplified logic for receipt amount () 2023-08-23 10:43:34 +09:00
c69f95bdce Do not block payments on LN while syncing if it is not internal node () 2023-08-22 13:45:50 +02:00
b3df403980 Fix LN payout manual payments UI crashing when payouts are not tied to pull payment 2023-08-15 15:11:04 +02:00
90ce75ee21 remove store ID from view request url () 2023-08-13 19:26:21 +02:00
1c5fcfe094 bump v 2023-08-11 15:55:11 +02:00
45c1fb42ee Changelog v1.11.2 2023-08-11 15:39:08 +02:00
64bd493996 POS: Unify item display ()
Display unifications for static and cart view.
2023-08-11 15:37:43 +02:00
ec6029409e Improve invoice filter wording ()
Closes .
2023-08-11 15:08:44 +02:00
c0fc31c69a Improve invoices status filter () 2023-08-10 20:23:18 +02:00
b5d0188f21 Receipt improvements () 2023-08-10 13:57:54 +02:00
0ccbaf4bd6 Greenfield: Fix invoice lookup by capitalized status ()
All statuses need to be lowercased before lookup, this wasn't the case for e.g. `Expired`.

Fixes .
2023-08-10 13:34:09 +02:00
ed43fb2071 POS fixes () 2023-08-09 14:47:28 +02:00
d67ebd957e POS: Handle flexible price items in cart view () 2023-08-09 09:31:19 +02:00
19d360a543 Fix: typo in InvoiceEntity.cs ()
Minumum -> Minimum
2023-08-07 09:26:37 +02:00
7dc41ebcea Email Rules: Improve validation ()
Came across this while testing things and the "Please fill all required fields before testing" message wasn't clear, because the required fields were not marked.

Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>
2023-08-07 09:10:48 +02:00
1eb7c727f3 POS fixes () 2023-08-05 10:44:59 +02:00
ede8171408 Checkout: Fix language select UI bug () 2023-08-04 07:44:50 +02:00
2538f3d8f6 fix https://github.com/Kukks/BTCPayServerPlugins/issues/18 2023-08-03 20:48:46 +02:00
ac64f5e395 Merge pull request from dennisreimann/supporters
Update supporters
2023-08-03 19:45:09 +02:00
1a7a731b54 Update supporters
Improve colors and visual balance
2023-08-03 14:58:32 +02:00
86f4d48bcb c-lightning to CLN; remove ptarmigan. () 2023-08-01 17:21:00 +03:00
83536bee88 Fix BTG rate provider 2023-07-29 10:00:34 +02:00
abfd6ea1dc update changelog and version 2023-07-29 09:48:47 +02:00
688e873f7a fixes 2023-07-29 09:15:12 +02:00
c88df08350 fixes 2023-07-29 09:15:11 +02:00
82586590a7 potentially fixes 2023-07-29 09:15:11 +02:00
88c66f30f2 fixes 2023-07-29 09:15:10 +02:00
9132592717 fixes 2023-07-29 09:15:10 +02:00
c0ffab768a fix ident 2023-07-29 09:15:10 +02:00
69190081c8 ui+checkout: fix language cutoff bug () 2023-07-28 21:24:30 +02:00
093206cf1e add changelog 2023-07-27 15:19:48 +02:00
a0110b7570 Merge remote-tracking branch 'origin/feat/changelog-1.11' 2023-07-27 15:14:53 +02:00
6d65feca4c update changelog 2023-07-27 08:39:58 +02:00
95be0242b6 add opensats and update strike logo ()
Co-authored-by: pavlenex <pavle@pavle.org>
2023-07-27 08:39:40 +02:00
79e121c3af Disabling playing of the invoice sound for existing stores 2023-07-26 10:42:00 -05:00
676ac2fe46 Changelog 1.11.0 2023-07-26 09:11:26 -05:00
8eabdab53a Preventing entering of negative tips and discounts in POS 2023-07-26 07:26:53 -05:00
957fb09ffc Reverting logic of how paid amount is displayed on the receipt 2023-07-26 07:26:32 -05:00
4bffe117a9 Do not show cheatmode in release, fix warnigns 2023-07-25 10:50:34 +09:00
05b01a13c8 Fix NRE error in PoS report 2023-07-24 23:20:17 +09:00
08e21c1a5d Fix report view 2023-07-24 23:13:11 +09:00
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
283 changed files with 9382 additions and 4767 deletions
BTCPayServer.Abstractions
BTCPayServer.Client
BTCPayServer.Common
BTCPayServer.Data
BTCPayServer.Rating
BTCPayServer.Tests
BTCPayServer
BTCPayServer.csproj
Blazor
Components
Controllers
Data
Extensions.cs
Extensions
FileTypeDetector.cs
Forms
HostedServices
Hosting
Models
PaymentRequest
Payments
PayoutProcessors
Plugins
Program.csSearchString.cs
Services
Storage
TagHelpers
Views
wwwroot
Build
Changelog.mdREADME.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;
}
}

@ -1,3 +1,4 @@
using System.Web;
using Ganss.XSS;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
@ -21,6 +22,11 @@ namespace BTCPayServer.Abstractions.Services
{
return _htmlHelper.Raw(_htmlSanitizer.Sanitize(value));
}
public IHtmlContent RawEncode(string value)
{
return _htmlHelper.Raw(HttpUtility.HtmlEncode(_htmlSanitizer.Sanitize(value)));
}
public IHtmlContent Json(object model)
{

@ -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; }

@ -16,7 +16,7 @@
<Platforms>AnyCPU</Platforms>
</PropertyGroup>
<PropertyGroup>
<Version Condition=" '$(Version)' == '' ">1.7.2</Version>
<Version Condition=" '$(Version)' == '' ">1.7.3</Version>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<PublishRepositoryUrl>true</PublishRepositoryUrl>
@ -32,7 +32,7 @@
<ItemGroup>
<PackageReference Include="BTCPayServer.Lightning.Common" Version="1.3.21" />
<PackageReference Include="NBitcoin" Version="7.0.24" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<None Include="icon.png" Pack="true" PackagePath="\" />

@ -55,7 +55,7 @@ namespace BTCPayServer.Client
}
public virtual async Task<IEnumerable<OnChainWalletTransactionData>> ShowOnChainWalletTransactions(
string storeId, string cryptoCode, TransactionStatus[] statusFilter = null, string labelFilter = null,
string storeId, string cryptoCode, TransactionStatus[] statusFilter = null, string labelFilter = null, int skip = 0,
CancellationToken token = default)
{
var query = new Dictionary<string, object>();
@ -67,6 +67,10 @@ namespace BTCPayServer.Client
{
query.Add(nameof(labelFilter), labelFilter);
}
if (skip != 0)
{
query.Add(nameof(skip), skip);
}
var response =
await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/wallet/transactions", query), token);

@ -37,6 +37,7 @@ namespace BTCPayServer.Client.Models
public string RedirectUrl { get; set; } = null;
public bool? RedirectAutomatically { get; set; } = null;
public bool? RequiresRefundEmail { get; set; } = null;
public bool? Archived { get; set; } = null;
public string FormId { get; set; } = null;
public string EmbeddedCSS { get; set; } = null;
public CheckoutType? CheckoutType { get; set; } = null;
@ -78,6 +79,7 @@ namespace BTCPayServer.Client.Models
public bool? DisplayPerksValue { get; set; } = null;
public bool? DisplayPerksRanking { get; set; } = null;
public bool? SortPerksByPopularity { get; set; } = null;
public bool? Archived { get; set; } = null;
public string[] Sounds { get; set; } = null;
public string[] AnimationColors { get; set; } = null;
}

@ -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; }
}
}

@ -9,6 +9,8 @@ namespace BTCPayServer.Client.Models
public string AppType { get; set; }
public string Name { get; set; }
public string StoreId { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public bool? Archived { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset Created { get; set; }
}

@ -45,6 +45,9 @@ namespace BTCPayServer.Client.Models
public bool LazyPaymentMethods { get; set; }
public bool RedirectAutomatically { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public bool Archived { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public bool ShowRecommendedFee { get; set; } = true;
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]

@ -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 = gate(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;

@ -14,6 +14,7 @@ namespace BTCPayServer.Data
public DateTimeOffset Created { get; set; }
public bool TagAllInvoices { get; set; }
public string Settings { get; set; }
public bool Archived { get; set; }
internal static void OnModelCreating(ModelBuilder builder)
{

@ -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
{

@ -48,6 +48,7 @@ namespace BTCPayServer.Data
public IEnumerable<StoreSettingData> Settings { get; set; }
public IEnumerable<FormData> Forms { get; set; }
public IEnumerable<StoreRole> StoreRoles { get; set; }
public bool Archived { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{

@ -0,0 +1,39 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20230906135844_AddArchivedFlagForStoresAndApps")]
public partial class AddArchivedFlagForStoresAndApps : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Archived",
table: "Stores",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Archived",
table: "Apps",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Archived",
table: "Stores");
migrationBuilder.DropColumn(
name: "Archived",
table: "Apps");
}
}
}

@ -79,6 +79,9 @@ namespace BTCPayServer.Migrations
b.Property<string>("AppType")
.HasColumnType("TEXT");
b.Property<bool>("Archived")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
@ -751,6 +754,9 @@ namespace BTCPayServer.Migrations
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<bool>("Archived")
.HasColumnType("INTEGER");
b.Property<string>("DefaultCrypto")
.HasColumnType("TEXT");

@ -7,7 +7,7 @@
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.3.1" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="5.2.9" />
<PackageReference Include="NBitcoin" Version="7.0.24" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="DigitalRuby.ExchangeSharp" Version="1.0.2" />
</ItemGroup>

@ -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();
}
}

@ -663,7 +663,7 @@ donation:
Assert.Equal(3, vmview.Items.Length);
Assert.Equal("good apple", vmview.Items[0].Title);
Assert.Equal("orange", vmview.Items[1].Title);
Assert.Equal(10.0m, vmview.Items[1].Price.Value);
Assert.Equal(10.0m, vmview.Items[1].Price);
Assert.Equal("{0} Purchase", vmview.ButtonText);
Assert.Equal("Nicolas Sexy Hair", vmview.CustomButtonText);
Assert.Equal("Wanna tip?", vmview.CustomTipText);
@ -680,7 +680,7 @@ donation:
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 0, choiceKey: "apple").Result);
invoices = user.BitPay.GetInvoices();
invoices = await user.BitPay.GetInvoicesAsync();
var appleInvoice = invoices.SingleOrDefault(invoice => invoice.ItemCode.Equals("apple"));
Assert.NotNull(appleInvoice);
Assert.Equal("good apple", appleInvoice.ItemDesc);
@ -689,7 +689,7 @@ donation:
var action = Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 6.6m, choiceKey: "donation").Result);
Assert.Equal(nameof(UIInvoiceController.Checkout), action.ActionName);
invoices = user.BitPay.GetInvoices();
invoices = await user.BitPay.GetInvoicesAsync();
var donationInvoice = invoices.Single(i => i.Price == 6.6m);
Assert.NotNull(donationInvoice);
Assert.Equal("CAD", donationInvoice.Currency);

@ -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="116.0.5845.9600" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<PrivateAssets>all</PrivateAssets>

@ -54,13 +54,33 @@ namespace BTCPayServer.Tests
Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps2.ListApps(user2.StoreId).Result).Model);
Assert.Single(appList.Apps);
Assert.Empty(appList2.Apps);
Assert.Equal("test", appList.Apps[0].AppName);
Assert.Equal(apps.CreatedAppId, appList.Apps[0].Id);
Assert.True(appList.Apps[0].Role.ToPermissionSet(appList.Apps[0].StoreId).Contains(Policies.CanModifyStoreSettings, appList.Apps[0].StoreId));
Assert.Equal(user.StoreId, appList.Apps[0].StoreId);
Assert.IsType<NotFoundResult>(apps2.DeleteApp(appList.Apps[0].Id));
Assert.IsType<ViewResult>(apps.DeleteApp(appList.Apps[0].Id));
var redirectToAction = Assert.IsType<RedirectToActionResult>(apps.DeleteAppPost(appList.Apps[0].Id).Result);
Assert.Equal("test", app.AppName);
Assert.Equal(apps.CreatedAppId, app.Id);
Assert.True(app.Role.ToPermissionSet(app.StoreId).Contains(Policies.CanModifyStoreSettings, app.StoreId));
Assert.Equal(user.StoreId, app.StoreId);
// Archive
redirect = Assert.IsType<RedirectResult>(apps.ToggleArchive(app.Id).Result);
Assert.EndsWith("/settings/crowdfund", redirect.Url);
appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
Assert.Empty(appList.Apps);
appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId, archived: true).Result).Model);
app = appList.Apps[0];
Assert.True(app.Archived);
Assert.IsType<NotFoundResult>(await crowdfund.ViewCrowdfund(app.Id));
// Unarchive
redirect = Assert.IsType<RedirectResult>(apps.ToggleArchive(app.Id).Result);
Assert.EndsWith("/settings/crowdfund", redirect.Url);
appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
app = appList.Apps[0];
Assert.False(app.Archived);
var crowdfundViewModel = await crowdfund.UpdateCrowdfund(app.Id).AssertViewModelAsync<UpdateCrowdfundViewModel>();
crowdfundViewModel.Enabled = true;
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel, "save").Result);
Assert.IsType<ViewResult>(await crowdfund.ViewCrowdfund(app.Id));
// Delete
Assert.IsType<NotFoundResult>(apps2.DeleteApp(app.Id));
Assert.IsType<ViewResult>(apps.DeleteApp(app.Id));
var redirectToAction = Assert.IsType<RedirectToActionResult>(apps.DeleteAppPost(app.Id).Result);
Assert.Equal(nameof(UIStoresController.Dashboard), redirectToAction.ActionName);
appList = await apps.ListApps(user.StoreId).AssertViewModelAsync<ListAppsViewModel>();
Assert.Empty(appList.Apps);

@ -1,4 +1,5 @@
using System;
using System.Diagnostics;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@ -196,6 +197,7 @@ retry:
driver.FindElement(selector).Click();
}
[DebuggerHidden]
public static bool ElementDoesNotExist(this IWebDriver driver, By selector)
{
Assert.Throws<NoSuchElementException>(() =>

@ -23,6 +23,7 @@ using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Rating;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Labels;
using BTCPayServer.Services.Rates;
@ -346,165 +347,272 @@ namespace BTCPayServer.Tests
Assert.True(Torrc.TryParse(input, out torrc));
Assert.Equal(expected, torrc.ToString());
}
[Fact]
public void CanParseCartItems()
{
Assert.True(AppService.TryParsePosCartItems(new JObject()
{
{"cart", new JArray()
{
new JObject()
{
{ "id", "ddd"},
{"price", 4},
{"count", 1}
}
}}
}, out var items));
Assert.Equal("ddd", items[0].Id);
Assert.Equal(1, items[0].Count);
Assert.Equal(4, items[0].Price);
// Using legacy parsing
Assert.True(AppService.TryParsePosCartItems(new JObject()
{
{"cart", new JArray()
{
new JObject()
{
{ "id", "ddd"},
{"price", new JObject()
{
{ "value", 8.49m }
}
},
{"count", 1}
}
}}
}, out items));
Assert.Equal("ddd", items[0].Id);
Assert.Equal(1, items[0].Count);
Assert.Equal(8.49m, items[0].Price);
Assert.False(AppService.TryParsePosCartItems(new JObject()
{
{"cart", new JArray()
{
new JObject()
{
{ "id", "ddd"},
{"price", new JObject()
{
{ "value", "nocrahs" }
}
},
{"count", 1}
}
}}
}, out items));
}
[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 +656,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 +719,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 +732,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 +1183,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 +1193,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 +1264,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 +2003,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 +2010,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 +2033,7 @@ namespace BTCPayServer.Tests
new PaymentEntity()
{
Accounted = true,
CryptoCode = "BTC",
Currency = "BTC",
NetworkFee = 0.00000100m,
Network = networkProvider.GetNetwork("BTC"),
}
@ -1889,34 +2042,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]

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
@ -16,7 +15,6 @@ using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.PayoutProcessors.OnChain;
using BTCPayServer.Services;
using BTCPayServer.Services.Custodian.Client.MockCustodian;
using BTCPayServer.Services.Notifications;
@ -24,7 +22,6 @@ using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using NBitcoin;
using NBitpayClient;
using Newtonsoft.Json;
@ -300,6 +297,7 @@ namespace BTCPayServer.Tests
Assert.Equal(user.StoreId, app.StoreId);
Assert.Equal("PointOfSale", app.AppType);
Assert.Equal("test app title", app.Title);
Assert.False(app.Archived);
// Make sure we return a 404 if we try to get an app that doesn't exist
await AssertHttpError(404, async () =>
@ -323,17 +321,20 @@ namespace BTCPayServer.Tests
new CreatePointOfSaleAppRequest()
{
AppName = "new app name",
Title = "new app title"
Title = "new app title",
Archived = true
}
);
// Test generic GET app endpoint first
retrievedApp = await client.GetApp(app.Id);
Assert.Equal("new app name", retrievedApp.Name);
Assert.True(retrievedApp.Archived);
// Test the POS-specific endpoint also
var retrievedPosApp = await client.GetPosApp(app.Id);
Assert.Equal("new app name", retrievedPosApp.Name);
Assert.Equal("new app title", retrievedPosApp.Title);
Assert.True(retrievedPosApp.Archived);
// Make sure we return a 404 if we try to delete an app that doesn't exist
await AssertHttpError(404, async () =>
@ -465,6 +466,7 @@ namespace BTCPayServer.Tests
Assert.Equal("test app from API", app.Name);
Assert.Equal(user.StoreId, app.StoreId);
Assert.Equal("Crowdfund", app.AppType);
Assert.False(app.Archived);
// Make sure we return a 404 if we try to get an app that doesn't exist
await AssertHttpError(404, async () =>
@ -481,11 +483,13 @@ namespace BTCPayServer.Tests
Assert.Equal(app.Name, retrievedApp.Name);
Assert.Equal(app.StoreId, retrievedApp.StoreId);
Assert.Equal(app.AppType, retrievedApp.AppType);
Assert.False(retrievedApp.Archived);
// Test the crowdfund-specific endpoint also
var retrievedPosApp = await client.GetCrowdfundApp(app.Id);
Assert.Equal(app.Name, retrievedPosApp.Name);
Assert.Equal(app.Title, retrievedPosApp.Title);
var retrievedCfApp = await client.GetCrowdfundApp(app.Id);
Assert.Equal(app.Name, retrievedCfApp.Name);
Assert.Equal(app.Title, retrievedCfApp.Title);
Assert.False(retrievedCfApp.Archived);
// Make sure we return a 404 if we try to delete an app that doesn't exist
await AssertHttpError(404, async () =>
@ -535,10 +539,12 @@ namespace BTCPayServer.Tests
Assert.Equal(posApp.Name, apps[0].Name);
Assert.Equal(posApp.StoreId, apps[0].StoreId);
Assert.Equal(posApp.AppType, apps[0].AppType);
Assert.False(apps[0].Archived);
Assert.Equal(crowdfundApp.Name, apps[1].Name);
Assert.Equal(crowdfundApp.StoreId, apps[1].StoreId);
Assert.Equal(crowdfundApp.AppType, apps[1].AppType);
Assert.False(apps[1].Archived);
// Get all apps for all store now
apps = await client.GetAllApps();
@ -548,15 +554,17 @@ namespace BTCPayServer.Tests
Assert.Equal(posApp.Name, apps[0].Name);
Assert.Equal(posApp.StoreId, apps[0].StoreId);
Assert.Equal(posApp.AppType, apps[0].AppType);
Assert.False(apps[0].Archived);
Assert.Equal(crowdfundApp.Name, apps[1].Name);
Assert.Equal(crowdfundApp.StoreId, apps[1].StoreId);
Assert.Equal(crowdfundApp.AppType, apps[1].AppType);
Assert.False(apps[1].Archived);
Assert.Equal(newApp.Name, apps[2].Name);
Assert.Equal(newApp.StoreId, apps[2].StoreId);
Assert.Equal(newApp.AppType, apps[2].AppType);
Assert.False(apps[2].Archived);
}
[Fact(Timeout = TestTimeout)]
@ -1151,7 +1159,8 @@ namespace BTCPayServer.Tests
Approved = false,
PaymentMethod = "BTC",
Amount = 0.0001m,
Destination = address.ToString()
Destination = address.ToString(),
});
await AssertAPIError("invalid-state", async () =>
{
@ -1270,7 +1279,7 @@ namespace BTCPayServer.Tests
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
user.GrantAccess();
await user.GrantAccessAsync();
await user.MakeAdmin();
var client = await user.CreateClient(Policies.Unrestricted);
@ -1349,6 +1358,13 @@ namespace BTCPayServer.Tests
}
tester.DeleteStore = false;
Assert.Empty(await client.GetStores());
// Archive
var archivableStore = await client.CreateStore(new CreateStoreRequest { Name = "Archivable" });
Assert.False(archivableStore.Archived);
archivableStore = await client.UpdateStore(archivableStore.Id, new UpdateStoreRequest { Name = "Archived", Archived = true });
Assert.Equal("Archived", archivableStore.Name);
Assert.True(archivableStore.Archived);
}
private async Task<GreenfieldValidationException> AssertValidationError(string[] fields, Func<Task> act)
@ -3210,6 +3226,9 @@ namespace BTCPayServer.Tests
});
var transaction = await client.GetOnChainWalletTransaction(walletId.StoreId, walletId.CryptoCode, txdata.TransactionHash.ToString());
// Check skip doesn't crash
await client.ShowOnChainWalletTransactions(walletId.StoreId, walletId.CryptoCode, skip: 1);
Assert.Equal(transaction.TransactionHash, txdata.TransactionHash);
Assert.Equal(String.Empty, transaction.Comment);
#pragma warning disable CS0612 // Type or member is obsolete
@ -3544,6 +3563,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) });
@ -3554,6 +3574,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)]
@ -3669,9 +3729,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 () =>
{
@ -3679,6 +3742,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)]

@ -1,8 +1,10 @@
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Hosting;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Plugins.Crowdfund.Models;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Plugins.PointOfSale.Models;
@ -123,12 +125,14 @@ donation:
price: 1.02
custom: true
";
vmpos.Currency = "EUR";
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
var publicApps = user.GetController<UIPointOfSaleController>();
var vmview = await publicApps.ViewPointOfSale(app.Id, PosViewType.Cart).AssertViewModelAsync<ViewPointOfSaleViewModel>();
Assert.Equal("EUR", vmview.CurrencyCode);
// apple shouldn't be available since we it's set to "disabled: true" above
Assert.Equal(2, vmview.Items.Length);
Assert.Equal("orange", vmview.Items[0].Title);
@ -139,6 +143,41 @@ donation:
// apple is not found
Assert.IsType<NotFoundResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 0, choiceKey: "apple").Result);
// List
appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
app = appList.Apps[0];
apps = user.GetController<UIAppsController>();
appData = new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType, Settings = "{\"currency\":\"EUR\"}" };
apps.HttpContext.SetAppData(appData);
pos.HttpContext.SetAppData(appData);
Assert.Single(appList.Apps);
Assert.Equal("test", app.AppName);
Assert.True(app.Role.ToPermissionSet(appList.Apps[0].StoreId).Contains(Policies.CanModifyStoreSettings, app.StoreId));
Assert.Equal(user.StoreId, app.StoreId);
Assert.False(app.Archived);
// Archive
redirect = Assert.IsType<RedirectResult>(apps.ToggleArchive(app.Id).Result);
Assert.EndsWith("/settings/pos", redirect.Url);
appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
Assert.Empty(appList.Apps);
appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId, archived: true).Result).Model);
app = appList.Apps[0];
Assert.True(app.Archived);
Assert.IsType<NotFoundResult>(await publicApps.ViewPointOfSale(app.Id, PosViewType.Static));
// Unarchive
redirect = Assert.IsType<RedirectResult>(apps.ToggleArchive(app.Id).Result);
Assert.EndsWith("/settings/pos", redirect.Url);
appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
app = appList.Apps[0];
Assert.False(app.Archived);
Assert.IsType<ViewResult>(await publicApps.ViewPointOfSale(app.Id, PosViewType.Static));
// Delete
Assert.IsType<ViewResult>(apps.DeleteApp(app.Id));
var redirectToAction = Assert.IsType<RedirectToActionResult>(apps.DeleteAppPost(app.Id).Result);
Assert.Equal(nameof(UIStoresController.Dashboard), redirectToAction.ActionName);
appList = await apps.ListApps(user.StoreId).AssertViewModelAsync<ListAppsViewModel>();
Assert.Empty(appList.Apps);
}
}
}

@ -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()

@ -57,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");
@ -247,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();
@ -314,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);
@ -349,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();
@ -356,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();
@ -378,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
@ -642,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();
@ -803,6 +809,27 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("ConfirmContinue")).Click();
s.GoToUrl(storeUrl);
Assert.Contains("ReturnUrl", s.Driver.Url);
// Archive store
(storeName, storeId) = s.CreateNewStore();
s.Driver.FindElement(By.Id("StoreSelectorToggle")).Click();
Assert.Contains(storeName, s.Driver.FindElement(By.Id("StoreSelectorMenu")).Text);
s.Driver.FindElement(By.Id($"StoreSelectorMenuItem-{storeId}")).Click();
s.GoToStore();
s.Driver.FindElement(By.Id("btn-archive-toggle")).Click();
Assert.Contains("The store has been archived and will no longer appear in the stores list by default.", s.FindAlertMessage().Text);
s.Driver.FindElement(By.Id("StoreSelectorToggle")).Click();
Assert.DoesNotContain(storeName, s.Driver.FindElement(By.Id("StoreSelectorMenu")).Text);
Assert.Contains("1 Archived Store", s.Driver.FindElement(By.Id("StoreSelectorMenu")).Text);
s.Driver.FindElement(By.Id("StoreSelectorArchived")).Click();
var storeLink = s.Driver.FindElement(By.Id($"Store-{storeId}"));
Assert.Contains(storeName, storeLink.Text);
storeLink.Click();
s.Driver.FindElement(By.Id("btn-archive-toggle")).Click();
Assert.Contains("The store has been unarchived and will appear in the stores list by default again.", s.FindAlertMessage().Text);
}
[Fact(Timeout = TestTimeout)]
@ -962,14 +989,17 @@ 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.Matches("\"categories\": \\[\n\\s+\"Drinks\"\n\\s+\\]", template);
s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text);
var appId = s.Driver.Url.Split('/')[4];
s.Driver.FindElement(By.Id("ViewApp")).Click();
var windows = s.Driver.WindowHandles;
@ -980,6 +1010,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(6, s.Driver.FindElements(By.CssSelector(".posItem.posItem--displayed")).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.posItem--displayed")));
s.Driver.FindElement(By.CssSelector("label[for='Category-*']")).Click();
Assert.Equal(6, s.Driver.FindElements(By.CssSelector(".posItem.posItem--displayed")).Count);
s.Driver.Url = posBaseUrl + "/static";
Assert.False(s.Driver.PageSource.Contains("Cart"), "Static PoS not showing correct view");
@ -1030,6 +1068,24 @@ namespace BTCPayServer.Tests
// We are only if explicitly going to /
s.GoToUrl("/");
Assert.Contains("Tea shop", s.Driver.PageSource);
// Archive
Assert.True(s.Driver.ElementDoesNotExist(By.Id("Nav-ArchivedApps")));
s.Driver.SwitchTo().Window(windows[0]);
s.Driver.FindElement(By.Id("btn-archive-toggle")).Click();
Assert.Contains("The app has been archived and will no longer appear in the apps list by default.", s.FindAlertMessage().Text);
Assert.True(s.Driver.ElementDoesNotExist(By.Id("ViewApp")));
Assert.Contains("1 Archived App", s.Driver.FindElement(By.Id("Nav-ArchivedApps")).Text);
s.Driver.Navigate().GoToUrl(posBaseUrl);
Assert.Contains("Page not found", s.Driver.Title, StringComparison.OrdinalIgnoreCase);
s.Driver.Navigate().Back();
s.Driver.FindElement(By.Id("Nav-ArchivedApps")).Click();
// Unarchive
s.Driver.FindElement(By.Id($"App-{appId}")).Click();
s.Driver.FindElement(By.Id("btn-archive-toggle")).Click();
Assert.Contains("The app has been unarchived and will appear in the apps list by default again.", s.FindAlertMessage().Text);
}
[Fact(Timeout = TestTimeout)]
@ -1063,17 +1119,37 @@ namespace BTCPayServer.Tests
s.Driver.ExecuteJavaScript("document.getElementById('EndDate').value = ''");
s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text);
var appId = s.Driver.Url.Split('/')[4];
s.Driver.FindElement(By.Id("ViewApp")).Click();
var windows = s.Driver.WindowHandles;
Assert.Equal(2, windows.Count);
s.Driver.SwitchTo().Window(windows[1]);
var cfUrl = s.Driver.Url;
Assert.Equal("Currently active!",
s.Driver.FindElement(By.CssSelector("[data-test='time-state']")).Text);
s.Driver.Close();
s.Driver.SwitchTo().Window(windows[0]);
// Archive
Assert.True(s.Driver.ElementDoesNotExist(By.Id("Nav-ArchivedApps")));
s.Driver.SwitchTo().Window(windows[0]);
s.Driver.FindElement(By.Id("btn-archive-toggle")).Click();
Assert.Contains("The app has been archived and will no longer appear in the apps list by default.", s.FindAlertMessage().Text);
Assert.True(s.Driver.ElementDoesNotExist(By.Id("ViewApp")));
Assert.Contains("1 Archived App", s.Driver.FindElement(By.Id("Nav-ArchivedApps")).Text);
s.Driver.Navigate().GoToUrl(cfUrl);
Assert.Contains("Page not found", s.Driver.Title, StringComparison.OrdinalIgnoreCase);
s.Driver.Navigate().Back();
s.Driver.FindElement(By.Id("Nav-ArchivedApps")).Click();
// Unarchive
s.Driver.FindElement(By.Id($"App-{appId}")).Click();
s.Driver.FindElement(By.Id("btn-archive-toggle")).Click();
Assert.Contains("The app has been unarchived and will appear in the apps list by default again.", s.FindAlertMessage().Text);
}
[Fact(Timeout = TestTimeout)]
@ -1146,12 +1222,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);
}
@ -2047,7 +2124,6 @@ namespace BTCPayServer.Tests
using var s = CreateSeleniumTester();
s.Server.ActivateLightning();
await s.StartAsync();
await s.Server.EnsureChannelsSetup();
s.RegisterNewUser(true);
@ -2084,7 +2160,6 @@ namespace BTCPayServer.Tests
using var s = CreateSeleniumTester();
s.Server.ActivateLightning();
await s.StartAsync();
await s.Server.EnsureChannelsSetup();
s.RegisterNewUser(true);
@ -2151,6 +2226,122 @@ namespace BTCPayServer.Tests
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")));
var posUrl = s.Driver.Url;
// 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 simple items
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(1) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Single(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(2) .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);
// Select item with inventory - two of it
Assert.Equal("5 left", s.Driver.FindElement(By.CssSelector(".posItem:nth-child(3) .badge.inventory")).Text);
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(3) .btn-primary")).Click();
Thread.Sleep(250);
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(3) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Equal(3, s.Driver.FindElements(By.CssSelector("#CartItems tr")).Count);
Assert.Equal("5,40 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Select items with minimum amount
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(5) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Equal(4, s.Driver.FindElements(By.CssSelector("#CartItems tr")).Count);
Assert.Equal("7,20 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Select items with adjusted minimum amount
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(5) input[name='amount']")).Clear();
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(5) input[name='amount']")).SendKeys("2.3");
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(5) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Equal(5, s.Driver.FindElements(By.CssSelector("#CartItems tr")).Count);
Assert.Equal("9,50 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Select items with custom amount
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(6) input[name='amount']")).Clear();
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(6) input[name='amount']")).SendKeys(".2");
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(6) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Equal(6, s.Driver.FindElements(By.CssSelector("#CartItems tr")).Count);
Assert.Equal("9,70 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Select items with another custom amount
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(6) input[name='amount']")).Clear();
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(6) input[name='amount']")).SendKeys(".3");
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(6) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Equal(7, s.Driver.FindElements(By.CssSelector("#CartItems tr")).Count);
Assert.Equal("10,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% = 1,00 €", s.Driver.FindElement(By.Id("CartDiscount")).Text);
Assert.Equal("9,00 €", 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,90 €", s.Driver.FindElement(By.Id("CartTip")).Text);
Assert.Equal("9,90 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Check values on checkout page
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("9,90 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
// Pay
s.PayInvoice();
// Check inventory got updated and is now 3 instead of 5
s.Driver.Navigate().GoToUrl(posUrl);
Assert.Equal("3 left", s.Driver.FindElement(By.CssSelector(".posItem:nth-child(3) .badge.inventory")).Text);
}
[Fact]
[Trait("Selenium", "Selenium")]
[Trait("Lightning", "Lightning")]
@ -2508,6 +2699,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();
@ -2519,14 +2711,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)
@ -2545,7 +2735,6 @@ retry:
}
}
[Fact]
[Trait("Selenium", "Selenium")]
public async Task CanUseLNURLAuth()
@ -2553,6 +2742,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"))
@ -2601,7 +2791,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;
}
}
}

@ -16,12 +16,14 @@ using BTCPayServer.Storage.Models;
using BTCPayServer.Storage.Services.Providers.AzureBlobStorage.Configuration;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.FileSystemGlobbing;
using NBitcoin;
using NBitpayClient;
using Newtonsoft.Json;
using Xunit;
using Xunit.Abstractions;
using Xunit.Sdk;
using static BTCPayServer.HostedServices.PullPaymentHostedService.PayoutApproval;
namespace BTCPayServer.Tests
{
@ -177,7 +179,7 @@ namespace BTCPayServer.Tests
Assert.Contains(rates, e => e.CurrencyPair == new CurrencyPair("XMR", "BTC") && e.BidAsk.Bid < 1.0m);
// Check we didn't skip too many exchanges
Assert.InRange(skipped, 0, 3);
Assert.InRange(skipped, 0, 5);
}
[Fact]
@ -290,9 +292,31 @@ retry:
}
[Fact]
public void CanGetRateCryptoCurrenciesByDefault()
public async Task CanGetRateFromRecommendedExchanges()
{
string[] brokenShitcoins = { "BTX_USD", "CHC_USD" };
var factory = FastTests.CreateBTCPayRateFactory();
var fetcher = new RateFetcher(factory);
var provider = new BTCPayNetworkProvider(ChainName.Mainnet);
var b = new StoreBlob();
foreach (var k in StoreBlob.RecommendedExchanges)
{
b.DefaultCurrency = k.Key;
var rules = b.GetDefaultRateRules(provider);
var pairs = new[] { CurrencyPair.Parse($"BTC_{k.Key}") }.ToHashSet();
var 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}");
Assert.True(rateResult.BidAsk != null, $"Impossible to get the rate {rateResult.EvaluatedRule}");
}
}
}
[Fact]
public async Task CanGetRateCryptoCurrenciesByDefault()
{
using var cts = new CancellationTokenSource(60_000);
var provider = new BTCPayNetworkProvider(ChainName.Mainnet);
var factory = FastTests.CreateBTCPayRateFactory();
var fetcher = new RateFetcher(factory);
@ -301,21 +325,29 @@ retry:
.Select(c => new CurrencyPair(c.CryptoCode, "USD"))
.ToHashSet();
string[] brokenShitcoins = { "BTG", "LCAD" };
bool IsBrokenShitcoin(CurrencyPair p) => brokenShitcoins.Contains(p.Left) || brokenShitcoins.Contains(p.Right);
foreach (var _ in brokenShitcoins)
{
foreach (var p in pairs.Where(IsBrokenShitcoin).ToArray())
{
TestLogs.LogInformation($"Skipping {p} because it is marked as broken");
pairs.Remove(p);
}
}
var rules = new StoreBlob().GetDefaultRateRules(provider);
var result = fetcher.FetchRates(pairs, rules, default);
var result = fetcher.FetchRates(pairs, rules, cts.Token);
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}");
}
}
[Fact]
[Trait("Fast", "Fast")]
public async Task CheckJsContent()
{
// This test verify that no malicious js is added in the minified files.
@ -324,52 +356,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();
Assert.Equal(expected, actual);
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)

@ -1700,109 +1700,6 @@ namespace BTCPayServer.Tests
}
}
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanExportInvoicesJson()
{
decimal GetFieldValue(string input, string fieldName)
{
var match = Regex.Match(input, $"\"{fieldName}\":([^,]*)");
Assert.True(match.Success);
return decimal.Parse(match.Groups[1].Value.Trim(), CultureInfo.InvariantCulture);
}
async Task<object[]> GetExport(TestAccount account, string storeId = null)
{
var content = await account.GetController<UIInvoiceController>(false)
.Export("json", storeId);
var result = Assert.IsType<ContentResult>(content);
Assert.Equal("application/json", result.ContentType);
return JsonConvert.DeserializeObject<object[]>(result.Content ?? "[]");
}
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
await user.GrantAccessAsync();
user.RegisterDerivationScheme("BTC");
await user.SetNetworkFeeMode(NetworkFeeMode.Always);
var invoice = await user.BitPay.CreateInvoiceAsync(
new Invoice
{
Price = 10,
Currency = "USD",
PosData = "posData",
OrderId = "orderId",
ItemDesc = "Some \", description",
FullNotifications = true
}, Facade.Merchant);
var networkFee = new FeeRate(invoice.MinerFees["BTC"].SatoshiPerBytes).GetFee(100);
var result = await GetExport(user);
Assert.Single(result);
var cashCow = tester.ExplorerNode;
var invoiceAddress = BitcoinAddress.Create(invoice.CryptoInfo[0].Address, cashCow.Network);
var firstPayment = invoice.CryptoInfo[0].TotalDue - 3 * networkFee;
cashCow.SendToAddress(invoiceAddress, firstPayment);
Thread.Sleep(1000); // prevent race conditions, ordering payments
// look if you can reduce thread sleep, this was min value for me
// should reduce invoice due by 0 USD because payment = network fee
cashCow.SendToAddress(invoiceAddress, networkFee);
Thread.Sleep(1000);
// pay remaining amount
cashCow.SendToAddress(invoiceAddress, 4 * networkFee);
Thread.Sleep(1000);
await TestUtils.EventuallyAsync(async () =>
{
var parsedJson = await GetExport(user);
Assert.Equal(3, parsedJson.Length);
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"));
Assert.Contains("\"InvoicePrice\": 10.0", pay1str);
Assert.Contains("\"ConversionRate\": 5000.0", pay1str);
Assert.Contains($"\"InvoiceId\": \"{invoice.Id}\",", pay1str);
var pay2str = parsedJson[1].ToString();
Assert.Equal(invoiceDueAfterFirstPayment, GetFieldValue(pay2str, "InvoiceDue"));
var pay3str = parsedJson[2].ToString();
Assert.Contains("\"InvoiceDue\": 0", pay3str);
});
// create an invoice for a new store and check responses with and without store id
var otherUser = tester.NewAccount();
await otherUser.GrantAccessAsync();
otherUser.RegisterDerivationScheme("BTC");
await otherUser.SetNetworkFeeMode(NetworkFeeMode.Always);
var newInvoice = await otherUser.BitPay.CreateInvoiceAsync(
new Invoice
{
Price = 21,
Currency = "USD",
PosData = "posData",
OrderId = "orderId",
ItemDesc = "Some \", description",
FullNotifications = true
}, Facade.Merchant);
await otherUser.PayInvoice(newInvoice.Id);
Assert.Single(await GetExport(otherUser));
Assert.Single(await GetExport(otherUser, otherUser.StoreId));
Assert.Equal(3, (await GetExport(user, user.StoreId)).Length);
Assert.Equal(3, (await GetExport(user)).Length);
await otherUser.AddOwner(user.UserId);
Assert.Equal(4, (await GetExport(user)).Length);
Assert.Single(await GetExport(user, otherUser.StoreId));
}
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanChangeNetworkFeeMode()
@ -1892,45 +1789,6 @@ namespace BTCPayServer.Tests
}
}
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanExportInvoicesCsv()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
await user.GrantAccessAsync();
user.RegisterDerivationScheme("BTC");
await user.SetNetworkFeeMode(NetworkFeeMode.Always);
var invoice = user.BitPay.CreateInvoice(
new Invoice
{
Price = 500,
Currency = "USD",
PosData = "posData",
OrderId = "orderId",
ItemDesc = "Some \", description",
FullNotifications = true
}, Facade.Merchant);
var cashCow = tester.ExplorerNode;
var invoiceAddress = BitcoinAddress.Create(invoice.CryptoInfo[0].Address, cashCow.Network);
var firstPayment = invoice.CryptoInfo[0].TotalDue - Money.Coins(0.001m);
cashCow.SendToAddress(invoiceAddress, firstPayment);
TestUtils.Eventually(() =>
{
var exportResultPaid =
user.GetController<UIInvoiceController>().Export("csv").GetAwaiter().GetResult();
var paidresult = Assert.IsType<ContentResult>(exportResultPaid);
Assert.Equal("application/csv", paidresult.ContentType);
Assert.Contains($",orderId,{invoice.Id},", paidresult.Content);
Assert.Contains($",On-Chain,BTC,0.0991,0.0001,5000.0", paidresult.Content);
Assert.Contains($",USD,5.00", paidresult.Content); // Seems hacky but some plateform does not render this decimal the same
Assert.Contains("0,,\"Some \"\", description\",New (paidPartial),new,paidPartial",
paidresult.Content);
});
}
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanCreateAndDeleteApps()
@ -2859,7 +2717,7 @@ namespace BTCPayServer.Tests
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
user.GrantAccess();
await user.GrantAccessAsync();
var controller = tester.PayTester.GetController<UIServerController>(user.UserId, user.StoreId);
var fileSystemStorageConfiguration = Assert.IsType<FileSystemStorageConfiguration>(Assert
@ -2874,7 +2732,6 @@ namespace BTCPayServer.Tests
Assert.Equal(StorageProvider.FileSystem,
shouldBeRedirectingToLocalStorageConfigPage.RouteValues["provider"]);
await CanUploadRemoveFiles(controller);
}
@ -2906,7 +2763,7 @@ namespace BTCPayServer.Tests
//create a temporary link to file
var tmpLinkGenerate = Assert.IsType<RedirectToActionResult>(await controller.CreateTemporaryFileUrl(fileId,
new UIServerController.CreateTemporaryFileUrlViewModel()
new UIServerController.CreateTemporaryFileUrlViewModel
{
IsDownload = true,
TimeAmount = 1,
@ -2936,5 +2793,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"];
}
}
}

@ -163,7 +163,7 @@ services:
- "bitcoin_datadir:/data"
customer_lightningd:
image: btcpayserver/lightning:v23.05-dev
image: btcpayserver/lightning:v23.08-dev
stop_signal: SIGKILL
restart: unless-stopped
environment:
@ -190,7 +190,7 @@ services:
- bitcoind
merchant_lightningd:
image: btcpayserver/lightning:v23.05-dev
image: btcpayserver/lightning:v23.08-dev
stop_signal: SIGKILL
environment:
EXPOSE_TCP: "true"
@ -224,7 +224,7 @@ services:
- "5432"
merchant_lnd:
image: btcpayserver/lnd:v0.16.3-beta
image: btcpayserver/lnd:v0.16.4-beta-1
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@ -259,7 +259,7 @@ services:
- bitcoind
customer_lnd:
image: btcpayserver/lnd:v0.16.3-beta
image: btcpayserver/lnd:v0.16.4-beta-1
restart: unless-stopped
environment:
LND_CHAIN: "btc"

@ -149,7 +149,7 @@ services:
- "bitcoin_datadir:/data"
customer_lightningd:
image: btcpayserver/lightning:v23.05-dev
image: btcpayserver/lightning:v23.08-dev
stop_signal: SIGKILL
restart: unless-stopped
environment:
@ -176,7 +176,7 @@ services:
- bitcoind
merchant_lightningd:
image: btcpayserver/lightning:v23.05-dev
image: btcpayserver/lightning:v23.08-dev
stop_signal: SIGKILL
environment:
EXPOSE_TCP: "true"
@ -211,7 +211,7 @@ services:
- "5432"
merchant_lnd:
image: btcpayserver/lnd:v0.16.3-beta
image: btcpayserver/lnd:v0.16.4-beta-1
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@ -248,7 +248,7 @@ services:
- bitcoind
customer_lnd:
image: btcpayserver/lnd:v0.16.3-beta
image: btcpayserver/lnd:v0.16.4-beta-1
restart: unless-stopped
environment:
LND_CHAIN: "btc"

@ -48,19 +48,19 @@
<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.28" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.4.31" />
<PackageReference Include="CsvHelper" Version="15.0.5" />
<PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Fido2" Version="2.0.2" />
<PackageReference Include="Fido2.AspNet" Version="2.0.2" />
<PackageReference Include="HtmlSanitizer" Version="5.0.372" />
<PackageReference Include="LNURL" Version="0.0.29" />
<PackageReference Include="LNURL" Version="0.0.31" />
<PackageReference Include="MailKit" Version="3.3.0" />
<PackageReference Include="BTCPayServer.NETCore.Plugins.Mvc" Version="1.4.4" />
<PackageReference Include="QRCoder" Version="1.4.3" />
<PackageReference Include="System.IO.Pipelines" Version="6.0.3" />
<PackageReference Include="NBitpayClient" Version="1.0.0.39" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NicolasDorier.CommandLine" Version="2.0.0" />
<PackageReference Include="NicolasDorier.CommandLine.Configuration" Version="2.0.0" />
<PackageReference Include="NicolasDorier.RateLimits" Version="1.2.3" />
@ -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" />
@ -136,6 +138,7 @@
<ItemGroup>
<Watch Include="Views\**\*.*"></Watch>
<Watch Remove="Views\UIAccount\CheatPermissions.cshtml" />
<Watch Remove="Views\UIReports\StoreReports.cshtml" />
<Content Update="Views\UIApps\_ViewImports.cshtml">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<Pack>$(IncludeRazorContentInPack)</Pack>

@ -0,0 +1,7 @@
@using System.Net.Http
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop

@ -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

@ -6,7 +6,10 @@
@using BTCPayServer.Views.Wallets
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Client
@using BTCPayServer.Components.ThemeSwitch
@using BTCPayServer.Components.UIExtensionPoint
@using BTCPayServer.Services
@using BTCPayServer.Views.Apps
@using BTCPayServer.Views.CustodianAccounts
@inject Microsoft.AspNetCore.Http.IHttpContextAccessor HttpContext;
@inject BTCPayServerEnvironment Env
@ -90,7 +93,7 @@
</li>
}
<vc:ui-extension-point location="store-wallets-nav" model="@Model"/>
<vc:ui-extension-point location="store-wallets-nav" model="@Model"/>
@if (PoliciesSettings.Experimental)
{
@foreach (var custodianAccount in Model.CustodianAccounts)
@ -131,6 +134,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"/>
@ -181,6 +190,14 @@
<span>Manage Plugins</span>
</a>
</li>
@if (Model.Store != null && Model.ArchivedAppsCount > 0)
{
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyStoreSettings">
<a asp-area="" asp-controller="UIApps" asp-action="ListApps" asp-route-storeId="@Model.Store.Id" asp-route-archived="true" class="nav-link @ViewData.IsActivePage(AppsNavPages.Index)" id="Nav-ArchivedApps">
@Model.ArchivedAppsCount Archived App@(Model.ArchivedAppsCount == 1 ? "" : "s")
</a>
</li>
}
</ul>
</div>
</div>

@ -68,13 +68,17 @@ namespace BTCPayServer.Components.MainNav
vm.LightningNodes = lightningNodes;
// Apps
var apps = await _appService.GetAllApps(UserId, false, store.Id);
vm.Apps = apps.Select(a => new StoreApp
{
Id = a.Id,
AppName = a.AppName,
AppType = a.AppType
}).ToList();
var apps = await _appService.GetAllApps(UserId, false, store.Id, true);
vm.Apps = apps
.Where(a => !a.Archived)
.Select(a => new StoreApp
{
Id = a.Id,
AppName = a.AppName,
AppType = a.AppType
}).ToList();
vm.ArchivedAppsCount = apps.Count(a => a.Archived);
if (PoliciesSettings.Experimental)
{

@ -1,7 +1,6 @@
using System.Collections.Generic;
using BTCPayServer.Data;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Services.Apps;
namespace BTCPayServer.Components.MainNav
{
@ -13,6 +12,7 @@ namespace BTCPayServer.Components.MainNav
public List<StoreApp> Apps { get; set; }
public CustodianAccountData[] CustodianAccounts { get; set; }
public bool AltcoinsBuild { get; set; }
public int ArchivedAppsCount { get; set; }
}
public class StoreApp

@ -1,3 +1,4 @@
@using BTCPayServer.Client
@model BTCPayServer.Components.StoreNumbers.StoreNumbersViewModel
<div class="widget store-numbers" id="StoreNumbers-@Model.Store.Id">
@ -21,26 +22,23 @@
}
else
{
<div class="store-number">
<header>
<h6>Paid invoices in the last @Model.TimeframeDays days</h6>
@if (Model.PaidInvoices > 0)
{
<a asp-controller="UIInvoice" asp-action="ListInvoices" asp-route-storeId="@Model.Store.Id" permission="@Policies.CanViewInvoices">View All</a>
}
</header>
<div class="h3">@Model.PaidInvoices</div>
</div>
<div class="store-number">
<header>
<h6>Payouts Pending</h6>
<a asp-controller="UIStorePullPayments" asp-action="Payouts" asp-route-storeId="@Model.Store.Id">Manage</a>
<a asp-controller="UIStorePullPayments" asp-action="Payouts" asp-route-storeId="@Model.Store.Id" permission="@Policies.CanManagePullPayments">Manage</a>
</header>
<div class="h3">@Model.PayoutsPending</div>
</div>
@if (Model.Transactions is not null)
{
<div class="store-number">
<header>
<h6>TXs in the last @Model.TransactionDays days</h6>
@if (Model.Transactions.Value > 0)
{
<a asp-controller="UIWallets" asp-action="WalletTransactions" asp-route-walletId="@Model.WalletId">View All</a>
}
</header>
<div class="h3">@Model.Transactions.Value</div>
</div>
}
<div class="store-number">
<header>
<h6>Refunds Issued</h6>

@ -6,6 +6,7 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Components.StoreRecentTransactions;
using BTCPayServer.Data;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Dapper;
@ -21,22 +22,16 @@ public class StoreNumbers : ViewComponent
{
private readonly StoreRepository _storeRepo;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly BTCPayWalletProvider _walletProvider;
private readonly NBXplorerConnectionFactory _nbxConnectionFactory;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly InvoiceRepository _invoiceRepository;
public StoreNumbers(
StoreRepository storeRepo,
ApplicationDbContextFactory dbContextFactory,
BTCPayNetworkProvider networkProvider,
BTCPayWalletProvider walletProvider,
NBXplorerConnectionFactory nbxConnectionFactory)
InvoiceRepository invoiceRepository)
{
_storeRepo = storeRepo;
_walletProvider = walletProvider;
_nbxConnectionFactory = nbxConnectionFactory;
_networkProvider = networkProvider;
_dbContextFactory = dbContextFactory;
_invoiceRepository = invoiceRepository;
}
public async Task<IViewComponentResult> InvokeAsync(StoreNumbersViewModel vm)
@ -52,28 +47,17 @@ public class StoreNumbers : ViewComponent
return View(vm);
await using var ctx = _dbContextFactory.CreateContext();
var payoutsCount = await ctx.Payouts
var offset = DateTimeOffset.Now.AddDays(-vm.TimeframeDays).ToUniversalTime();
vm.PaidInvoices = await _invoiceRepository.GetInvoiceCount(
new InvoiceQuery { StoreId = new [] { vm.Store.Id }, StartDate = offset, Status = new [] { "paid", "confirmed" } });
vm.PayoutsPending = await ctx.Payouts
.Where(p => p.PullPaymentData.StoreId == vm.Store.Id && !p.PullPaymentData.Archived && p.State == PayoutState.AwaitingApproval)
.CountAsync();
var refundsCount = await ctx.Invoices
.Where(i => i.StoreData.Id == vm.Store.Id && !i.Archived && i.CurrentRefundId != null)
vm.RefundsIssued = await ctx.Invoices
.Where(i => i.StoreData.Id == vm.Store.Id && !i.Archived && i.CurrentRefundId != null && i.Created >= offset)
.CountAsync();
var derivation = vm.Store.GetDerivationSchemeSettings(_networkProvider, vm.CryptoCode);
int? transactionsCount = null;
if (derivation != null && _nbxConnectionFactory.Available)
{
await using var conn = await _nbxConnectionFactory.OpenConnection();
var wid = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id(derivation.Network.CryptoCode, derivation.AccountDerivation.ToString());
var afterDate = DateTimeOffset.UtcNow - TimeSpan.FromDays(vm.TransactionDays);
var count = await conn.ExecuteScalarAsync<long>("SELECT COUNT(*) FROM wallets_history WHERE code=@code AND wallet_id=@wid AND seen_at > @afterDate", new { code = derivation.Network.CryptoCode, wid, afterDate });
transactionsCount = (int)count;
}
vm.PayoutsPending = payoutsCount;
vm.Transactions = transactionsCount;
vm.RefundsIssued = refundsCount;
return View(vm);
}
}

@ -7,9 +7,9 @@ public class StoreNumbersViewModel
public StoreData Store { get; set; }
public WalletId WalletId { get; set; }
public int PayoutsPending { get; set; }
public int? Transactions { get; set; }
public int TimeframeDays { get; set; } = 7;
public int? PaidInvoices { get; set; }
public int RefundsIssued { get; set; }
public int TransactionDays { get; set; } = 7;
public bool InitialRendering { get; set; }
public string CryptoCode { get; set; }
}

@ -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,27 +52,41 @@
<a asp-controller="UIInvoice" asp-action="Invoice" asp-route-invoiceId="@invoice.InvoiceId" class="text-break">@invoice.InvoiceId</a>
</td>
<td>
@if (invoice.Details.Archived)
{
<span class="badge bg-warning">archived</span>
}
<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>
@foreach (var paymentType in invoice.Details.Payments.Select(payment => payment.GetPaymentMethodId()?.PaymentType).Distinct().Where(type => type != null && !string.IsNullOrEmpty(type.GetBadge())))
{
<span class="badge">@paymentType.GetBadge()</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,8 +1,9 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.Contracts
@using BTCPayServer.Client
@using BTCPayServer.Components.MainLogo
@using BTCPayServer.Services
@using BTCPayServer.Views.Stores
@inject BTCPayServerEnvironment Env
@inject IFileService FileService
@model BTCPayServer.Components.StoreSelector.StoreSelectorViewModel
@ -34,7 +35,7 @@ else
<a asp-controller="UIStores" asp-action="Dashboard" permission="@Policies.CanModifyStoreSettings" asp-route-storeId="@Model.CurrentStoreId" id="StoreSelectorHome" class="navbar-brand py-2">@{await LogoContent();}</a>
<a asp-controller="UIInvoice" asp-action="ListInvoices" not-permission="@Policies.CanModifyStoreSettings" asp-route-storeId="@Model.CurrentStoreId" id="StoreSelectorHome" class="navbar-brand py-2">@{await LogoContent();}</a>
}
@if (Model.Options.Any())
@if (Model.Options.Any() || Model.ArchivedCount > 0)
{
<div id="StoreSelector">
<div id="StoreSelectorDropdown" class="dropdown only-for-js">
@ -64,8 +65,16 @@ else
}
</li>
}
<li><hr class="dropdown-divider"></li>
<li><a asp-controller="UIUserStores" asp-action="CreateStore" class="dropdown-item" id="StoreSelectorCreate">Create Store</a></li>
@if (Model.Options.Any())
{
<li><hr class="dropdown-divider"></li>
}
<li><a asp-controller="UIUserStores" asp-action="CreateStore" class="dropdown-item @ViewData.IsActivePage(StoreNavPages.Create)" id="StoreSelectorCreate">Create Store</a></li>
@if (Model.ArchivedCount > 0)
{
<li><hr class="dropdown-divider"></li>
<li><a asp-controller="UIUserStores" asp-action="ListStores" asp-route-archived="true" class="dropdown-item @ViewData.IsActivePage(StoreNavPages.Index)" id="StoreSelectorArchived">@Model.ArchivedCount Archived Store@(Model.ArchivedCount == 1 ? "" : "s")</a></li>
}
</ul>
</div>
</div>

@ -30,7 +30,9 @@ namespace BTCPayServer.Components.StoreSelector
var userId = _userManager.GetUserId(UserClaimsPrincipal);
var stores = await _storeRepo.GetStoresByUserId(userId);
var currentStore = ViewContext.HttpContext.GetStoreData();
var archivedCount = stores.Count(s => s.Archived);
var options = stores
.Where(store => !store.Archived)
.Select(store =>
{
var cryptoCode = store
@ -59,7 +61,8 @@ namespace BTCPayServer.Components.StoreSelector
Options = options,
CurrentStoreId = currentStore?.Id,
CurrentDisplayName = currentStore?.StoreName,
CurrentStoreLogoFileId = blob?.LogoFileId
CurrentStoreLogoFileId = blob?.LogoFileId,
ArchivedCount = archivedCount
};
return View(vm);

@ -8,6 +8,7 @@ namespace BTCPayServer.Components.StoreSelector
public string CurrentStoreId { get; set; }
public string CurrentStoreLogoFileId { get; set; }
public string CurrentDisplayName { get; set; }
public int ArchivedCount { get; set; }
}
public class StoreSelectorOption

@ -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,15 +1,20 @@
@using BTCPayServer.Services;
@using BTCPayServer.Views.Stores
@using BTCPayServer.Client
@using BTCPayServer.Views.Wallets
@using BTCPayServer.Abstractions.Extensions
@inject DisplayFormatter DisplayFormatter
@model BTCPayServer.Components.WalletNav.WalletNavViewModel
<div class="d-sm-flex align-items-center justify-content-between">
<a asp-controller="UIWallets" asp-action="WalletTransactions" asp-route-walletId="@Model.WalletId" class="unobtrusive-link">
<h2 class="mb-1">@Model.Label</h2>
<div class="text-muted fw-semibold" data-sensitive>
@Model.Balance @Model.Network.CryptoCode
@DisplayFormatter.Currency(Model.Balance, Model.Network.CryptoCode)
@if (!string.IsNullOrEmpty(Model.BalanceDefaultCurrency))
{
<span>(@DisplayFormatter.Currency(Model.BalanceDefaultCurrency, Model.DefaultCurrency))</span>
}
</div>
</a>
<div class="d-flex gap-3 mt-3 mt-sm-0" permission="@Policies.CanModifyStoreSettings">

@ -1,4 +1,5 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@ -7,8 +8,10 @@ using BTCPayServer.Data;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Identity;
@ -23,16 +26,22 @@ namespace BTCPayServer.Components.WalletNav
{
private readonly BTCPayWalletProvider _walletProvider;
private readonly UIWalletsController _walletsController;
private readonly CurrencyNameTable _currencies;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly IRateProvider _rateProvider;
public WalletNav(
BTCPayWalletProvider walletProvider,
BTCPayNetworkProvider networkProvider,
UIWalletsController walletsController)
UIWalletsController walletsController,
CurrencyNameTable currencies,
IRateProvider rateProvider)
{
_walletProvider = walletProvider;
_networkProvider = networkProvider;
_walletsController = walletsController;
_currencies = currencies;
_rateProvider = rateProvider;
}
public async Task<IViewComponentResult> InvokeAsync(WalletId walletId)
@ -40,17 +49,35 @@ namespace BTCPayServer.Components.WalletNav
var store = ViewContext.HttpContext.GetStoreData();
var network = _networkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
var wallet = _walletProvider.GetWallet(network);
var defaultCurrency = store.GetStoreBlob().DefaultCurrency;
var derivation = store.GetDerivationSchemeSettings(_networkProvider, walletId.CryptoCode);
var balance = await _walletsController.GetBalanceString(wallet, derivation?.AccountDerivation);
var balance = await wallet.GetBalance(derivation?.AccountDerivation) switch
{
{ Available: null, Total: var total } => total,
{ Available: var available } => available
};
var vm = new WalletNavViewModel
{
WalletId = walletId,
Network = network,
Balance = balance,
Balance = balance.ShowMoney(network),
DefaultCurrency = defaultCurrency,
Label = derivation?.Label ?? $"{store.StoreName} {walletId.CryptoCode} Wallet"
};
if (defaultCurrency != network.CryptoCode)
{
var rates = await _rateProvider.GetRatesAsync(default);
var rate = rates.FirstOrDefault(pair => pair.CurrencyPair.Right == defaultCurrency &&
pair.CurrencyPair.Left == network.CryptoCode);
if (rate != null)
{
var currencyData = _currencies.GetCurrencyData(defaultCurrency, false);
vm.BalanceDefaultCurrency = (balance.GetValue() * rate.BidAsk.Bid).ShowMoney(currencyData.Divisibility);
}
}
return View(vm);
}
}

@ -6,5 +6,7 @@ namespace BTCPayServer.Components.WalletNav
public BTCPayNetwork Network { get; set; }
public string Label { get; set; }
public string Balance { get; set; }
public string BalanceDefaultCurrency { get; set; }
public string DefaultCurrency { get; set; }
}
}

@ -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;
}
}
}

@ -66,7 +66,8 @@ namespace BTCPayServer.Controllers.Greenfield
{
StoreDataId = storeId,
Name = request.AppName,
AppType = CrowdfundAppType.AppType
AppType = CrowdfundAppType.AppType,
Archived = request.Archived ?? false
};
appData.SetSettings(ToCrowdfundSettings(request));
@ -97,7 +98,8 @@ namespace BTCPayServer.Controllers.Greenfield
{
StoreDataId = storeId,
Name = request.AppName,
AppType = PointOfSaleAppType.AppType
AppType = PointOfSaleAppType.AppType,
Archived = request.Archived ?? false
};
appData.SetSettings(ToPointOfSaleSettings(request));
@ -111,7 +113,7 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> UpdatePointOfSaleApp(string appId, CreatePointOfSaleAppRequest request)
{
var app = await _appService.GetApp(appId, PointOfSaleAppType.AppType);
var app = await _appService.GetApp(appId, PointOfSaleAppType.AppType, includeArchived: true);
if (app == null)
{
return AppNotFound();
@ -129,6 +131,10 @@ namespace BTCPayServer.Controllers.Greenfield
}
app.Name = request.AppName;
if (request.Archived != null)
{
app.Archived = request.Archived.Value;
}
app.SetSettings(ToPointOfSaleSettings(request));
await _appService.UpdateOrCreateApp(app);
@ -153,7 +159,7 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetAllApps()
{
var apps = await _appService.GetAllApps(_userManager.GetUserId(User));
var apps = await _appService.GetAllApps(_userManager.GetUserId(User), includeArchived: true);
return Ok(apps.Select(ToModel).ToArray());
}
@ -162,7 +168,7 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetAllApps(string storeId)
{
var apps = await _appService.GetAllApps(_userManager.GetUserId(User), allowNoUser: false, storeId);
var apps = await _appService.GetAllApps(_userManager.GetUserId(User), false, storeId, true);
return Ok(apps.Select(ToModel).ToArray());
}
@ -171,7 +177,7 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetApp(string appId)
{
var app = await _appService.GetApp(appId, null);
var app = await _appService.GetApp(appId, null, includeArchived: true);
if (app == null)
{
return AppNotFound();
@ -184,7 +190,7 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetPosApp(string appId)
{
var app = await _appService.GetApp(appId, PointOfSaleAppType.AppType);
var app = await _appService.GetApp(appId, PointOfSaleAppType.AppType, includeArchived: true);
if (app == null)
{
return AppNotFound();
@ -197,7 +203,7 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetCrowdfundApp(string appId)
{
var app = await _appService.GetApp(appId, CrowdfundAppType.AppType);
var app = await _appService.GetApp(appId, CrowdfundAppType.AppType, includeArchived: true);
if (app == null)
{
return AppNotFound();
@ -209,7 +215,7 @@ namespace BTCPayServer.Controllers.Greenfield
[HttpDelete("~/api/v1/apps/{appId}")]
public async Task<IActionResult> DeleteApp(string appId)
{
var app = await _appService.GetApp(appId, null);
var app = await _appService.GetApp(appId, null, includeArchived: true);
if (app == null)
{
return AppNotFound();
@ -293,6 +299,7 @@ namespace BTCPayServer.Controllers.Greenfield
return new AppDataBase
{
Id = appData.Id,
Archived = appData.Archived,
AppType = appData.AppType,
Name = appData.Name,
StoreId = appData.StoreDataId,
@ -305,6 +312,7 @@ namespace BTCPayServer.Controllers.Greenfield
return new AppDataBase
{
Id = appData.Id,
Archived = appData.Archived,
AppType = appData.AppType,
Name = appData.AppName,
StoreId = appData.StoreId,
@ -319,6 +327,7 @@ namespace BTCPayServer.Controllers.Greenfield
return new PointOfSaleAppData
{
Id = appData.Id,
Archived = appData.Archived,
AppType = appData.AppType,
Name = appData.Name,
StoreId = appData.StoreDataId,
@ -387,6 +396,7 @@ namespace BTCPayServer.Controllers.Greenfield
return new CrowdfundAppData
{
Id = appData.Id,
Archived = appData.Archived,
AppType = appData.AppType,
Name = appData.Name,
StoreId = appData.StoreDataId,

@ -396,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(
@ -464,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;
@ -580,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()),

@ -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
@ -284,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;
@ -321,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);
@ -393,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;
@ -415,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() ?? new LightningAutomatedPayoutBlob();
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
};
}

@ -33,6 +33,7 @@ namespace BTCPayServer.Controllers.Greenfield
_storeRepository = storeRepository;
_userManager = userManager;
}
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores")]
public Task<ActionResult<IEnumerable<Client.Models.StoreData>>> GetStores()
@ -112,7 +113,7 @@ namespace BTCPayServer.Controllers.Greenfield
return Ok(FromModel(store));
}
internal static Client.Models.StoreData FromModel(Data.StoreData data)
internal static Client.Models.StoreData FromModel(StoreData data)
{
var storeBlob = data.GetStoreBlob();
return new Client.Models.StoreData
@ -120,6 +121,7 @@ namespace BTCPayServer.Controllers.Greenfield
Id = data.Id,
Name = data.StoreName,
Website = data.StoreWebsite,
Archived = data.Archived,
SupportUrl = storeBlob.StoreSupportUrl,
SpeedPolicy = data.SpeedPolicy,
DefaultPaymentMethod = data.GetDefaultPaymentId()?.ToStringNormalized(),
@ -166,6 +168,7 @@ namespace BTCPayServer.Controllers.Greenfield
var blob = model.GetStoreBlob();
model.StoreName = restModel.Name;
model.StoreWebsite = restModel.Website;
model.Archived = restModel.Archived;
model.SpeedPolicy = restModel.SpeedPolicy;
model.SetDefaultPaymentId(defaultPaymentMethod);
//we do not include the default payment method in this model and instead opt to set it in the stores/storeid/payment-methods endpoints

@ -759,7 +759,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
public override async Task<IEnumerable<OnChainWalletTransactionData>> ShowOnChainWalletTransactions(
string storeId, string cryptoCode, TransactionStatus[] statusFilter = null, string labelFilter = null,
string storeId, string cryptoCode, TransactionStatus[] statusFilter = null, string labelFilter = null, int skip = 0,
CancellationToken token = default)
{
return GetFromActionResult<IEnumerable<OnChainWalletTransactionData>>(

@ -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; }
@ -69,28 +76,26 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> ListApps(
string storeId,
string sortOrder = null,
string sortOrderColumn = null
string sortOrderColumn = null,
bool archived = false
)
{
var store = GetCurrentStore();
var apps = await _appService.GetAllApps(GetUserId(), false, store.Id);
var apps = (await _appService.GetAllApps(GetUserId(), false, store.Id, archived))
.Where(app => app.Archived == archived);
if (sortOrder != null && sortOrderColumn != null)
{
apps = apps.OrderByDescending(app =>
{
switch (sortOrderColumn)
return sortOrderColumn switch
{
case nameof(app.AppName):
return app.AppName;
case nameof(app.StoreName):
return app.StoreName;
case nameof(app.AppType):
return app.AppType;
default:
return app.Id;
}
}).ToArray();
nameof(app.AppName) => app.AppName,
nameof(app.StoreName) => app.StoreName,
nameof(app.AppType) => app.AppType,
_ => app.Id
};
});
switch (sortOrder)
{
@ -98,7 +103,7 @@ namespace BTCPayServer.Controllers
ViewData[$"{sortOrderColumn}SortOrder"] = "asc";
break;
case "asc":
apps = apps.Reverse().ToArray();
apps = apps.Reverse();
ViewData[$"{sortOrderColumn}SortOrder"] = "desc";
break;
}
@ -106,7 +111,7 @@ namespace BTCPayServer.Controllers
return View(new ListAppsViewModel
{
Apps = apps
Apps = apps.ToArray()
});
}
@ -154,7 +159,6 @@ namespace BTCPayServer.Controllers
TempData[WellKnownTempData.SuccessMessage] = "App successfully created";
CreatedAppId = appData.Id;
var url = await type.ConfigureLink(appData);
return Redirect(url);
}
@ -183,14 +187,81 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(UIStoresController.Dashboard), "UIStores", new { storeId = app.StoreDataId });
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpPost("{appId}/archive")]
public async Task<IActionResult> ToggleArchive(string appId)
{
var app = GetCurrentApp();
if (app == null)
return NotFound();
var type = _appService.GetAppType(app.AppType);
if (type is null)
{
return UnprocessableEntity();
}
var archived = !app.Archived;
if (await _appService.SetArchived(app, archived))
{
TempData[WellKnownTempData.SuccessMessage] = archived
? "The app has been archived and will no longer appear in the apps list by default."
: "The app has been unarchived and will appear in the apps list by default again.";
}
else
{
TempData[WellKnownTempData.ErrorMessage] = $"Failed to {(archived ? "archive" : "unarchive")} the app.";
}
var url = await type.ConfigureLink(app);
return Redirect(url);
}
[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,16 +16,14 @@ 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;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Invoices.Export;
using BTCPayServer.Services.Rates;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -124,6 +122,7 @@ namespace BTCPayServer.Controllers
var additionalData = metaData
.Where(dict => !InvoiceAdditionalDataExclude.Contains(dict.Key))
.ToDictionary(dict => dict.Key, dict => dict.Value);
var model = new InvoiceDetailsModel
{
StoreId = store.Id,
@ -150,7 +149,6 @@ namespace BTCPayServer.Controllers
StatusException = invoice.ExceptionStatus,
Events = invoice.Events,
Metadata = metaData,
AdditionalData = additionalData,
Archived = invoice.Archived,
CanRefund = invoiceState.CanRefund(),
Refunds = invoice.Refunds,
@ -167,6 +165,27 @@ namespace BTCPayServer.Controllers
model.CryptoPayments = details.CryptoPayments;
model.Payments = details.Payments;
model.Overpaid = details.Overpaid;
if (additionalData.ContainsKey("receiptData"))
{
model.ReceiptData = (Dictionary<string, object>)additionalData["receiptData"];
additionalData.Remove("receiptData");
}
if (additionalData.ContainsKey("posData") && additionalData["posData"] is string posData)
{
// overwrite with parsed JSON if possible
try
{
additionalData["posData"] = PosDataParser.ParsePosData(JObject.Parse(posData));
}
catch (Exception)
{
additionalData["posData"] = posData;
}
}
model.AdditionalData = additionalData;
return View(model);
}
@ -228,18 +247,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.InvoicePaidAmount.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.InvoicePaidAmount.Net, i.Currency, DisplayFormatter.CurrencyFormat.Symbol),
RateFormatted = _displayFormatter.Currency(paymentEntity.Rate, i.Currency, DisplayFormatter.CurrencyFormat.Symbol),
PaymentMethod = paymentMethodId.ToPrettyString(),
Link = link,
Id = txId,
@ -251,7 +266,7 @@ namespace BTCPayServer.Controllers
.Where(payment => payment != null)
.ToList();
vm.Amount = payments.Sum(p => p!.Paid);
vm.Amount = i.PaidAmount.Net;
vm.Payments = receipt.ShowPayments is false ? null : payments;
vm.AdditionalData = PosDataParser.ParsePosData(receiptData);
@ -364,8 +379,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);
}
@ -560,7 +575,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 +586,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 +842,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 +899,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 +974,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 +1113,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 +1153,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,
@ -1147,41 +1181,6 @@ namespace BTCPayServer.Controllers
};
}
[HttpGet]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewInvoices)]
[BitpayAPIConstraint(false)]
public async Task<IActionResult> Export(string format, string? storeId = null, string? searchTerm = null, int timezoneOffset = 0)
{
var model = new InvoiceExport(_CurrencyNameTable);
var fs = new SearchString(searchTerm);
var storeIds = new HashSet<string>();
if (storeId is not null)
{
storeIds.Add(storeId);
}
if (fs.GetFilterArray("storeid") is { } l)
{
foreach (var i in l)
storeIds.Add(i);
}
InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, timezoneOffset);
invoiceQuery.StoreId = storeIds.ToArray();
invoiceQuery.Skip = 0;
invoiceQuery.Take = int.MaxValue;
var invoices = await _InvoiceRepository.GetInvoices(invoiceQuery);
var res = model.Process(invoices, format);
var cd = new ContentDisposition
{
FileName = $"btcpay-export-{DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture)}.{format}",
Inline = true
};
Response.Headers.Add("Content-Disposition", cd.ToString());
Response.Headers.Add("X-Content-Type-Options", "nosniff");
return Content(res, "application/" + format);
}
private SelectList GetPaymentMethodsSelectList()
{
var store = GetCurrentStore();
@ -1273,32 +1272,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,14 +5,13 @@ 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;
@ -24,16 +23,13 @@ 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
@ -60,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; }
@ -84,6 +81,7 @@ namespace BTCPayServer.Controllers
InvoiceActivator invoiceActivator,
LinkGenerator linkGenerator,
AppService appService,
IFileService fileService,
IAuthorizationService authorizationService)
{
_displayFormatter = displayFormatter;
@ -105,101 +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");
}
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 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;
@ -237,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);
@ -314,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()
@ -402,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)
@ -506,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)
@ -547,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;
}
}
}

@ -296,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()
{
@ -306,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;
@ -318,7 +318,6 @@ namespace BTCPayServer
}
createInvoice.Metadata = invoiceMetadata.ToJObject();
return await GetLNURLRequest(
cryptoCode,
store,
@ -443,28 +442,37 @@ namespace BTCPayServer
}
[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
});
}
@ -538,7 +546,7 @@ 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;
}
@ -706,6 +714,7 @@ namespace BTCPayServer
try
{
var expiry = i.ExpirationTime.ToUniversalTime() - DateTimeOffset.UtcNow;
HttpContext.Items.Add(nameof(invoiceId), invoiceId);
var description = (await _pluginHookService.ApplyFilter("modify-lnurlp-description", lnurlPayRequest.Metadata)) as string;
if (description is null)
return NotFound();

@ -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);
}
}
}

@ -199,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;
@ -38,6 +39,7 @@ namespace BTCPayServer.Controllers
private readonly PullPaymentHostedService _pullPaymentService;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
private readonly IAuthorizationService _authorizationService;
public StoreData CurrentStore
{
@ -53,7 +55,8 @@ namespace BTCPayServer.Controllers
DisplayFormatter displayFormatter,
PullPaymentHostedService pullPaymentHostedService,
ApplicationDbContextFactory dbContextFactory,
BTCPayNetworkJsonSerializerSettings jsonSerializerSettings)
BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
IAuthorizationService authorizationService)
{
_btcPayNetworkProvider = btcPayNetworkProvider;
_payoutHandlers = payoutHandlers;
@ -62,10 +65,11 @@ namespace BTCPayServer.Controllers
_pullPaymentService = pullPaymentHostedService;
_dbContextFactory = dbContextFactory;
_jsonSerializerSettings = jsonSerializerSettings;
_authorizationService = authorizationService;
}
[HttpGet("stores/{storeId}/pull-payments/new")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanCreateNonApprovedPullPayments, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> NewPullPayment(string storeId)
{
if (CurrentStore is null)
@ -94,7 +98,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("stores/{storeId}/pull-payments/new")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanCreateNonApprovedPullPayments, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> NewPullPayment(string storeId, NewPullPaymentModel model)
{
if (CurrentStore is null)
@ -134,6 +138,11 @@ namespace BTCPayServer.Controllers
}
if (!ModelState.IsValid)
return View(model);
if (model.AutoApproveClaims)
{
model.AutoApproveClaims = (await
_authorizationService.AuthorizeAsync(User, storeId, Policies.CanCreatePullPayments)).Succeeded;
}
await _pullPaymentService.CreatePullPayment(new HostedServices.CreatePullPayment()
{
Name = model.Name,
@ -529,10 +538,33 @@ namespace BTCPayServer.Controllers
{
var ppBlob = item.PullPayment?.GetBlob();
var payoutBlob = item.Payout.GetBlob(_jsonSerializerSettings);
item.Payout.PullPaymentData = item.PullPayment;
string payoutSource = item.Payout.GetPayoutSource(_jsonSerializerSettings);
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),

@ -10,6 +10,7 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Services.Mails;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Mvc;
using MimeKit;
@ -75,33 +76,22 @@ namespace BTCPayServer.Controllers
if (command.StartsWith("test", StringComparison.InvariantCultureIgnoreCase))
{
var rule = vm.Rules[index];
if (string.IsNullOrEmpty(rule.Subject) || string.IsNullOrEmpty(rule.Body) || string.IsNullOrEmpty(rule.To))
try
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Warning,
Message = "Please fill all required fields before testing"
});
}
else
{
try
{
var emailSettings = blob.EmailSettings;
using var client = await emailSettings.CreateSmtpClient();
var message = emailSettings.CreateMailMessage(MailboxAddress.Parse(rule.To), "(test) " + rule.Subject, rule.Body, true);
await client.SendAsync(message);
await client.DisconnectAsync(true);
TempData[WellKnownTempData.SuccessMessage] = $"Rule email saved and sent to {rule.To}. Please verify you received it.";
var emailSettings = blob.EmailSettings;
using var client = await emailSettings.CreateSmtpClient();
var message = emailSettings.CreateMailMessage(MailboxAddress.Parse(rule.To), "(test) " + rule.Subject, rule.Body, true);
await client.SendAsync(message);
await client.DisconnectAsync(true);
TempData[WellKnownTempData.SuccessMessage] = $"Rule email saved and sent to {rule.To}. Please verify you received it.";
blob.EmailRules = vm.Rules;
store.SetStoreBlob(blob);
await _Repo.UpdateStore(store);
}
catch (Exception ex)
{
TempData[WellKnownTempData.ErrorMessage] = "Error: " + ex.Message;
}
blob.EmailRules = vm.Rules;
store.SetStoreBlob(blob);
await _Repo.UpdateStore(store);
}
catch (Exception ex)
{
TempData[WellKnownTempData.ErrorMessage] = "Error: " + ex.Message;
}
}
else
@ -128,10 +118,18 @@ namespace BTCPayServer.Controllers
{
[Required]
public WebhookEventType Trigger { get; set; }
public bool CustomerEmail { get; set; }
[Required]
[MailboxAddress]
public string To { get; set; }
public string Body { get; set; }
[Required]
public string Subject { get; set; }
[Required]
public string Body { get; set; }
}
[HttpGet("{storeId}/email-settings")]

@ -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;
@ -626,6 +680,7 @@ namespace BTCPayServer.Controllers
InvoiceExpiration = (int)storeBlob.InvoiceExpiration.TotalMinutes,
DefaultCurrency = storeBlob.DefaultCurrency,
BOLT11Expiration = (long)storeBlob.RefundBOLT11Expiration.TotalDays,
Archived = store.Archived,
CanDelete = _Repo.CanDeleteStores()
};
@ -674,28 +729,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 +758,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 +774,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 +799,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}");
}
}
}
@ -775,6 +828,23 @@ namespace BTCPayServer.Controllers
});
}
[HttpPost("{storeId}/archive")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)]
public async Task<IActionResult> ToggleArchive(string storeId)
{
CurrentStore.Archived = !CurrentStore.Archived;
await _Repo.UpdateStore(CurrentStore);
TempData[WellKnownTempData.SuccessMessage] = CurrentStore.Archived
? "The store has been archived and will no longer appear in the stores list by default."
: "The store has been unarchived and will appear in the stores list by default again.";
return RedirectToAction(nameof(GeneralSettings), new
{
storeId = CurrentStore.Id
});
}
[HttpGet("{storeId}/delete")]
public IActionResult DeleteStore(string storeId)
{

@ -1,12 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Rating;
using BTCPayServer.Services.Rates;
@ -37,14 +35,34 @@ namespace BTCPayServer.Controllers
_rateFactory = rateFactory;
}
[HttpGet()]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettingsUnscoped)]
public async Task<IActionResult> ListStores(bool archived = false)
{
var stores = await _repo.GetStoresByUserId(GetUserId());
var vm = new ListStoresViewModel
{
Stores = stores
.Where(s => s.Archived == archived)
.Select(s => new ListStoresViewModel.StoreViewModel
{
StoreId = s.Id,
StoreName = s.StoreName,
Archived = s.Archived
}).ToList(),
Archived = archived
};
return View(vm);
}
[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)
};

@ -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;
}
}

@ -72,7 +72,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
return (await dbContext.Payouts
.Include(data => data.PullPaymentData)
.ThenInclude(data => data.StoreData)
.Include(data => data.StoreData)
.ThenInclude(data => data.UserStores)
.ThenInclude(data => data.StoreRole)
.Where(data =>
@ -82,11 +82,11 @@ namespace BTCPayServer.Data.Payouts.LightningLike
.ToListAsync())
.Where(payout =>
{
if (approvedStores.TryGetValue(payout.PullPaymentData.StoreId, out var value))
if (approvedStores.TryGetValue(payout.StoreDataId, out var value))
return value;
value = payout.PullPaymentData.StoreData.UserStores
value = payout.StoreData.UserStores
.Any(store => store.ApplicationUserId == userId && store.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings));
approvedStores.Add(payout.PullPaymentData.StoreId, value);
approvedStores.Add(payout.StoreDataId, value);
return value;
}).ToList();
}
@ -125,7 +125,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
await using var ctx = _applicationDbContextFactory.CreateContext();
var payouts = (await GetPayouts(ctx, pmi, payoutIds)).GroupBy(data => data.PullPaymentData.StoreId);
var payouts = (await GetPayouts(ctx, pmi, payoutIds)).GroupBy(data => data.StoreDataId);
var results = new List<ResultVM>();
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(pmi.CryptoCode);
@ -134,7 +134,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
var authorizedForInternalNode = (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanModifyServerSettings))).Succeeded;
foreach (var payoutDatas in payouts)
{
var store = payoutDatas.First().PullPaymentData.StoreData;
var store = payoutDatas.First().StoreData;
var lightningSupportedPaymentMethod = store.GetSupportedPaymentMethods(_btcPayNetworkProvider)
.OfType<LightningSupportedPaymentMethod>()
@ -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; }
}
}

@ -33,9 +33,27 @@ namespace BTCPayServer.Data
return PaymentMethodId.TryParse(data.PaymentMethodId, out var paymentMethodId) ? paymentMethodId : null;
}
public static string GetPayoutSource(this PayoutData data, BTCPayNetworkJsonSerializerSettings jsonSerializerSettings)
{
var ppBlob = data.PullPaymentData?.GetBlob();
var payoutBlob = data.GetBlob(jsonSerializerSettings);
string payoutSource;
if (payoutBlob.Metadata?.TryGetValue("source", StringComparison.InvariantCultureIgnoreCase,
out var source) is true)
{
return source.Value<string>();
}
else
{
return ppBlob?.Name ?? data.PullPaymentDataId;
}
}
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(false)]
[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);
@ -124,6 +135,14 @@ namespace BTCPayServer
}
}
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)
where T : class, IPeriodicTask
{
@ -382,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),
};
}

@ -203,9 +203,25 @@ public class UIFormsController : Controller
if (store is null)
return NotFound();
try
{
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 });
}
catch (Exception e)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "Could not generate invoice: "+ e.Message
});
return await GetFormView(formData, form);
}
}
}

@ -1,4 +1,5 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@ -50,45 +51,47 @@ namespace BTCPayServer.HostedServices
}
}).Where(tuple => tuple.Data != null && tuple.Items.Any(item =>
item.Inventory.HasValue &&
updateAppInventory.Items.ContainsKey(item.Id)));
foreach (var valueTuple in apps)
updateAppInventory.Items.FirstOrDefault(i => i.Id == item.Id) != null));
foreach (var app in apps)
{
foreach (var item1 in valueTuple.Items.Where(item =>
updateAppInventory.Items.ContainsKey(item.Id)))
foreach (var cartItem in updateAppInventory.Items)
{
var item = app.Items.FirstOrDefault(item => item.Id == cartItem.Id);
if (item == null) continue;
if (updateAppInventory.Deduct)
{
item1.Inventory -= updateAppInventory.Items[item1.Id];
item.Inventory -= cartItem.Count;
}
else
{
item1.Inventory += updateAppInventory.Items[item1.Id];
item.Inventory += cartItem.Count;
}
}
switch (valueTuple.Data.AppType)
switch (app.Data.AppType)
{
case PointOfSaleAppType.AppType:
((PointOfSaleSettings)valueTuple.Settings).Template =
AppService.SerializeTemplate(valueTuple.Items);
((PointOfSaleSettings)app.Settings).Template =
AppService.SerializeTemplate(app.Items);
break;
case CrowdfundAppType.AppType:
((CrowdfundSettings)valueTuple.Settings).PerksTemplate =
AppService.SerializeTemplate(valueTuple.Items);
((CrowdfundSettings)app.Settings).PerksTemplate =
AppService.SerializeTemplate(app.Items);
break;
default:
throw new InvalidOperationException();
}
valueTuple.Data.SetSettings(valueTuple.Settings);
await _appService.UpdateOrCreateApp(valueTuple.Data);
app.Data.SetSettings(app.Settings);
await _appService.UpdateOrCreateApp(app.Data);
}
}
else if (evt is InvoiceEvent invoiceEvent)
{
Dictionary<string, int> cartItems = null;
List<PosCartItem> cartItems = null;
bool deduct;
switch (invoiceEvent.Name)
{
@ -104,8 +107,8 @@ namespace BTCPayServer.HostedServices
return;
}
if ((!string.IsNullOrEmpty(invoiceEvent.Invoice.Metadata.ItemCode) ||
AppService.TryParsePosCartItems(invoiceEvent.Invoice.Metadata.PosData, out cartItems)))
if (!string.IsNullOrEmpty(invoiceEvent.Invoice.Metadata.ItemCode) ||
AppService.TryParsePosCartItems(invoiceEvent.Invoice.Metadata.PosData, out cartItems))
{
var appIds = AppService.GetAppInternalTags(invoiceEvent.Invoice);
@ -114,13 +117,18 @@ namespace BTCPayServer.HostedServices
return;
}
var items = cartItems ?? new Dictionary<string, int>();
var items = cartItems?.ToList() ?? new List<PosCartItem>();
if (!string.IsNullOrEmpty(invoiceEvent.Invoice.Metadata.ItemCode))
{
items.TryAdd(invoiceEvent.Invoice.Metadata.ItemCode, 1);
items.Add(new PosCartItem
{
Id = invoiceEvent.Invoice.Metadata.ItemCode,
Count = 1,
Price = invoiceEvent.Invoice.Price
});
}
_eventAggregator.Publish(new UpdateAppInventory()
_eventAggregator.Publish(new UpdateAppInventory
{
Deduct = deduct,
Items = items,
@ -134,7 +142,7 @@ namespace BTCPayServer.HostedServices
public class UpdateAppInventory
{
public string[] AppId { get; set; }
public Dictionary<string, int> Items { get; set; }
public List<PosCartItem> Items { get; set; }
public bool Deduct { get; set; }
public override string ToString()

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