Compare commits

..

140 Commits

Author SHA1 Message Date
b2480ad081 Update Changelog 2024-07-15 22:00:52 +09:00
c9687622e9 Shopify: Cancel rather than close an order () 2024-07-15 22:00:39 +09:00
7711acd1e9 Shopify: Create invoice when the payment page opens () 2024-07-15 22:00:32 +09:00
a580f67991 Add changelog and bump 1.13.4 ()
* Add changelog and bump 1.13.4

* Update Changelog.md

---------

Co-authored-by: d11n <mail@dennisreimann.de>
2024-07-11 22:39:48 +09:00
63a3667406 Timespan in API should be parsed with invariant culture 2024-07-11 21:40:44 +09:00
daeeb58f71 Checkout: Display description if present () 2024-07-11 21:26:40 +09:00
ff7e96b35f Handle LNURL Payouts failing due to amount restriction ()
* Handle LNURL payouts better when amount is not allowee

* docker-compose: Add missing restart for merchant CLN container

* show sats not msats

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2024-07-11 10:37:28 +09:00
43fa3cea53 Truncate Center Component: Fix Vue rendering ()
* Truncate Center Component: Fix Vue rendering

* Simplify the TruncateCenter component

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2024-07-11 10:34:22 +09:00
5c2ff32842 Fix Monero development environment with wallet load () 2024-07-11 10:30:00 +09:00
14113f9468 Add setup script for dev basics like users and stores ()
* Add setup script for dev basics like users and stores

I'm using and extending this script for setting up the basics after I erase my dev containers. Once you clean out everything with `cd BTCPayServer.Tests && docker-compose down --volumes --remove-orphans && docker-compose up dev` you otherwise have to recreate everything manually. This gives you ...

- An admin user with unrestricted API key
- One additional user per default role
- Store 1: Satoshis Steaks with Hot Wallet and Internal Node (and all users assigned to that store)
- Store 2: Nakamoto Nuggets with Hot Wallet and Merchant LND Lightning node (and all users besides Guest assigned to that store)
- Nakamoto Nuggets also gets Cart and Keypad apps
- Store 3 with External Lightning based store with Customer LND Lightning node

## Sample output

```bash
Admin ID: 78aa0b35-6c72-45ac-a7d4-b7976ebbbb62
Admin API Key: 992023ae659295b14c3b429007bbf67c2fec057d

Store Owner ID: e3151462-b0f8-4342-879e-16e42d3432d9
Store Manager ID: d0f11a4d-7c9f-466d-bbb9-dfba09295446
Store Employee ID: 13a882de-65f1-4be9-819e-be058e54a8a9

Satoshis Steaks Store ID: FDyaDcDxtSnNx77nEtT8VL55tcitcrV3Zoj5B6eoByEL

Nakamoto Nuggets Store ID: 8uJqvtPnvCU1XXBiSNBLGEn5XinwC1qYcyP495pPzn9a
Nakamoto Nuggets Keypad POS ID: 2q3Z6b8RUfwrvMyYngNAbj8kPqU8
Nakamoto Nuggets Cart POS ID: 2TfyrzZjiWnYp9QwWyy4U7y5dAFP

External Lightning Store ID: Cr56Ch7h3cgGPcbsZnKWnqCisMojfAdUVJhsN3zLcqSP
```

* Fix path issue
2024-07-11 10:29:47 +09:00
3cc5c07dec Fix tests ()
* docker-compose: Add missing restart for merchant CLN container

* Upgrade ChromeDriver

* Fix path and test
2024-07-11 10:25:39 +09:00
4429d0d631 Disable plugins if they crash the Dashboard page () 2024-07-11 10:20:02 +09:00
fa7ea62ab2 Add AdditionalData to PointOfSaleBaseData 2024-07-11 10:19:28 +09:00
ef64b11f7a TimeSpan JSON Converter: Parse strings ()
Something I came across while working in the app: Without this, the [TimeSpan default values of the StoreBlob](https://github.com/btcpayserver/btcpayserver/blob/master/BTCPayServer/Data/StoreBlob.cs#L84) cannot be parsed and don't get applied properly.
2024-07-11 10:14:29 +09:00
9cbea55c2a bump selenium container () 2024-07-11 10:13:06 +09:00
6ad1c962ff Bumping LND to 0.18.1-beta () 2024-07-11 10:08:48 +09:00
eaef28ae7f Bump EF libs () 2024-07-11 10:08:33 +09:00
80f9e313bc Fix Monero local dev docker start up () 2024-07-11 09:59:00 +09:00
f2f97bb468 Greenfield: Fix missing delete app policy () 2024-07-10 16:11:52 +02:00
69f7eb11bc Receipt page fixes ()
* Receipt: Don't assign empty values to data; hide present empty values

* Receipt: Use same URL on "Return to Store" link as on invoice
2024-07-09 23:56:34 +09:00
5bf5f4fc2b TransactionLinkProviders: Don't force single item ()
* TransactionLinkProviders: Don't force single item

Fixes .

* docker-compose: Add missing restart for merchant CLN container
2024-07-09 22:56:43 +09:00
35041751e0 Fix email settings on store level, when server has no email settings ()
* Fix email settings on store level, when server has no email settings

Fixes .

* docker-compose: Add missing restart for merchant CLN container
2024-07-09 22:51:16 +09:00
8aae388d95 Greenfield: Add missing invoice metadata to Lightning Address ()
* Greenfield: Add missing invoice metadata to Lightning Address

Closes .

* Update BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-lightning-addresses.json
2024-07-04 16:25:25 +02:00
201d6cfe70 Reports: Fix dropdown z-index 2024-07-04 15:37:13 +02:00
597e2b0ec1 Fix: Reports rows weren't always properly sorted (Fix ) 2024-07-04 18:09:03 +09:00
058a3ee96a Do not crash when refunding an invoice that has been marked settled (Fix ) 2024-07-04 16:50:27 +09:00
05f3539818 Do not crash the plugin packer if Assembly.GetTypes crashes 2024-06-21 10:16:55 +09:00
9d84ec4aa4 bump v 2024-06-13 14:48:48 +02:00
33f20b7be5 v1.13.3: Add Changelog ()
* v1.13.3: Add Changelog

* Update Changelog.md

---------

Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>
2024-06-13 14:47:17 +02:00
487f967607 bump cln 2024-06-12 19:39:55 +02:00
5bc9285e84 Pull payment: Enable CORS for LNURL request ()
Fixes  and ensures Mutiny Wallet can do the LNURL request.
2024-06-12 11:14:58 +02:00
bc1a5aa8f0 Fix null pointer exception on receipt print page ()
* Fix null pointer exception on receipt print page

* Adding test to verify receipt printing working as expected

* Update Chrome WebDriver

* Add print receipt tests

* Remove duplicate test

---------

Co-authored-by: rockstardev <5191402+rockstardev@users.noreply.github.com>
2024-06-11 16:04:25 +02:00
556a9c0e6d Fixing Fast Tests ()
* Fixing Fast Tests

* Revert file header change

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2024-06-11 12:53:50 +02:00
a026d244fe Fix: Invoice paid for topping up a pull payment weren't topping anything 2024-06-06 23:11:01 +09:00
5884850e22 POS: Don't show free items in print view ()
We cannot generate a proper LNURL response for free items, hence we should not show them in the print view.

Closes .
2024-06-05 22:12:12 +09:00
b341536e42 fix shopify 2024-06-05 08:39:43 +02:00
8356c0d5e5 Refactor shopify logic
This refactors the logic around shopify to keep it in one place. invoice Statuses are handled in a more streamlined way.

# Conflicts:
#	BTCPayServer/Plugins/Shopify/ShopifyOrderMarkerHostedService.cs
2024-06-04 14:29:40 +02:00
6d2f886717 Update changelog 2024-06-03 22:04:03 +09:00
f9aae4ab3d Cancel shopify order when invoice payment fails ()
* Cancel shopify order when invoice payment fails

* void correctly and invoice logs
2024-06-03 22:04:02 +09:00
1ecf0d25a9 Bumping LND to 0.18-0-beta () 2024-06-01 19:27:37 -05:00
c7231fe092 Fix: Adding a label to a base58 addresses in the Send Wallet screen wasn't working () 2024-05-27 23:17:18 +09:00
8922c3de59 Add changelog for 1.13.2 () 2024-05-24 16:13:52 +09:00
fefb99dfa2 Dashboard: Add table-responsive wrapper for transactions and invoices ()
Fixes .
2024-05-24 14:12:10 +09:00
a3b0bbe861 Sanitizer: Allow bitcoin and lightning URI schemes ()
Fixes .
2024-05-24 14:12:01 +09:00
3dd562ffdc Fix tests 2024-05-23 22:22:49 +09:00
b19db7291d Bump dependencies () 2024-05-23 22:18:30 +09:00
70253cbd9f Search: Display text filters in search input ()
* Search: Display text filters in search input

This changes the search text input to also display the filters, which don't have a special UI (e.g. dropdown). Those filters (e.g. orderid) were not displayed before and hence could not be reset.

Fixes .

* Add and fix test
2024-05-23 20:22:36 +09:00
887803a328 POS: Allow overpay for articles with minimum price ()
Adjust the LNURL settings so that minimum price items aren't capped and can be overpaid.

Also fixes the missing LNURL response for free items to return a proper LNURL error instead of just 404.

Fixes .
2024-05-23 20:21:30 +09:00
42da90f7dc Improve data display on receipt ()
Once more an improvement for the receipt, which also fixes :

- Unify data displayed on the web and print version
- Split cart and additional data and ensure additional data is displayed
- Do not display extra subtotal row if there are no tips or discounts
- Make PosData partial more universal and backwards-compatible by using case insensitive key lookups
2024-05-23 19:58:13 +09:00
1152f68aed fix bolt 11 0 amount check for payouts ()
Co-authored-by: d11n <mail@dennisreimann.de>
2024-05-23 19:40:26 +09:00
9124aeb1ee Domain mapping constraint: Fix .onion case ()
Fixes , which is a regression introduced in . The Tor-check must happen to prevent redirecting, but we must still return true if we already resolved an `appId`.
2024-05-23 19:39:37 +09:00
a35c5d8289 Pull payment QR scan fixes ()
Updates the icon and fixes a JS error, in case the LNURL/Boltcard option isn't available.
2024-05-23 19:24:41 +09:00
e24b42ef95 Server email settings: Fix missing password field ()
Fixes .
2024-05-23 19:24:24 +09:00
e10937c253 Add refund reports ()
* Add refund reports

* Fix fake data generator in reports
2024-05-23 19:23:11 +09:00
96b90d2444 Greenfield API clarifications, fix typo subscriptions -> registrations () 2024-05-23 19:20:25 +09:00
600bbb9ce0 Theme docs link fix ()
We recently removed the section the anchor links to and we'll remove the links entirely in .
2024-05-23 19:19:12 +09:00
fe9e5eb9c9 Fix: Some valid taproot PSBT couldn't parsed and show better error message (Fix ) () 2024-05-23 19:16:53 +09:00
cb136cba82 Allow negative payouts in pull payments 2024-05-17 16:31:44 +09:00
b3240f28b5 Minor refactorings 2024-05-17 14:46:17 +09:00
fe32cbd8be Can hide the footer of LayoutSimple 2024-05-17 09:27:46 +09:00
51fcf52da1 Show boltcard deeplink also on iOS 2024-05-17 09:10:15 +09:00
3f02c0d30a Checking if display cart is needed to save space on receipt 2024-05-16 09:43:33 +09:00
bae1f4e20b Cutting off lightning payment so receipt takes less space 2024-05-16 09:41:50 +09:00
3fbc717cd4 Show better error message for invalid destination in PullPayments 2024-05-03 22:58:28 +09:00
958a348fed bump NTag424 lib 2024-04-24 21:50:31 +09:00
57226fc97f Remove debug logs 2024-04-22 10:54:56 +09:00
ca55e1f300 bump 2024-04-15 22:27:09 +09:00
8b02c0bd82 Prevent payout double send ()
Fixes .
2024-04-15 22:06:00 +09:00
b92ff7c27b Bump CLightning () 2024-04-15 18:22:51 +09:00
d24761a498 update csv export to include full date and time in 12 hour format ()
* update csv export to include full date and time in 12 hour format

* formatting the export datetime to 24 hours
2024-04-15 12:22:18 +09:00
c78ee24d0a Fix: Unable to use a different postgres schema (Fix ) 2024-04-15 11:38:56 +09:00
172dd507bd Small payment request fixes ()
* Do not crash payment request page on 0 amount

* set email from form to payment request
2024-04-15 09:11:19 +09:00
fdd4790023 (bug) handle null XMR settings ()
Co-authored-by: Henry Hollingworth <henry.hollingworth@alcoa.com>
2024-04-15 09:11:08 +09:00
4ebe46830b Fix JSContent test 2024-04-09 11:13:07 +09:00
a2df9ed44c fix: switch to using get_info for monerod () 2024-04-09 11:12:38 +09:00
6ae474d214 Adding Tether as BTCPay Server Foundation Supporter ()
* Adding Tether as BTCPay Server Foundation Supporter

* Adding Tether to _BTCPaySupporters partial as well

* Modfying supporter_strike.svg to have white backgroundf or dark mode

* Modifying supporter_tether.svg to fit in the 150x100 box

* Centering Tether shape
2024-04-09 11:12:06 +09:00
5b31d4de20 v1.13: Update changelog () 2024-03-28 22:40:18 +09:00
14f8c73b08 POS: Increase size of quantity buttons ()
Closes .
2024-03-28 09:01:56 +09:00
529075f64c Make "Employee" default role on store settings ()
* Refactoring: Use property rather than injecting StoreRepository

* Update info text

* Make "Employee" default role on store settings

Closes .
2024-03-28 09:01:34 +09:00
dba102e74f Template Editor: Fix mobile view ()
Fixes .
2024-03-27 19:20:49 +09:00
0f3f8b6bf9 Contact Us improvements ()
* Add contact link to sidebar

Closes .

* Obfuscate contact email on public pages

Closes .

* Fix
2024-03-27 19:19:39 +09:00
83028b9b73 Adding introduction, Authentication and Usage examples sections to the API docs. () 2024-03-24 00:02:01 +09:00
1fe766cb16 Keypad: Fix images () 2024-03-22 15:16:59 +01:00
6b45eb0d3d Do not throw when local node is not synced and using external ln node ()
* Do not throw when local node is not synced and using external ln node

* Fix additional bug when ln conn strings without server would crash
2024-03-22 10:06:38 +01:00
6b0087ab69 Update version 2024-03-21 21:40:09 +09:00
e60fd8d6ab Changelog v1.13 () 2024-03-21 21:39:06 +09:00
88a1d83323 Support bbqr psbts ()
* Support bbqr psbts

https://bbqr.org/ @nvk

* add js test for bbqr
2024-03-21 10:30:23 +01:00
e21a8df0f3 smaller printed receipts () 2024-03-21 17:30:34 +09:00
93f37b506b UI: Improve Create First Store view ()
Unifies the width and display with the login view.
2024-03-21 07:37:15 +01:00
fca3480e37 Specify mailto: prefix for emails in Server Settings ()
* Specify mailto: prefix for emails in Server Settings

* resolve test failure

* Update wording

* Apply mailto-prefix on setting change

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2024-03-19 15:04:09 +01:00
966547db54 Template Editor: Apply item changes directly ()
Closes .
2024-03-19 14:59:26 +01:00
09dbe44bca Onboarding: Invite new users on store level ()
* Onboarding: Invite new users

- Separates the user self-registration and invite cases
- Adds invitation email for users created by the admin
- Adds invitation tokens to verify user was invited
- Adds handler action for invite links
- Refactors `UserEventHostedService`
- Fixes .

* Add permissioned form tag helper

* Better way of changing a user's role

* Test fixes
2024-03-19 14:58:33 +01:00
b7ce6b7400 Providing additional parameter for info message ()
* Providing additional parameter for info message

* Refactoring code to remove parameter and only set status message in LoadFromBIP21 if not present

* Update BTCPayServer/Controllers/UIWalletsController.cs

---------

Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>
2024-03-18 12:17:30 +01:00
78f169cd24 chore: remove repetitive words ()
chore: remove repetitive words

Signed-off-by: soonsouth <cuibuwei@163.com>
2024-03-18 10:49:07 +01:00
d0e11f1ec4 changing check box to toggle in various setting views ()
* Resolves: check box to toggle in various setting views

* resolve conflicts

* Notification logic reversal

* remove transform property in the toggle

* Handle email tls certificate check

* Unifications and fixes

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2024-03-14 15:16:48 +01:00
912a706de9 Make Payouts and PullPayments columns JSONB () 2024-03-14 11:13:26 +01:00
9b5c8a8254 POS: Add item list to keypad ()
* Add admin option to show item list for keypad view

* Refactor common POS Vue mixin

* Add item list to POS keypad

* Add recent transactions to cart

* Keypad: Pass tip and discount as cart does

* Keypad and cart tests

* Improve offcanvas button

---------

Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>
2024-03-14 11:11:54 +01:00
e5adc630af If pull payment opened in mobile, use deeplink to setup card ()
* If pull payment opened in mobile, use deeplink to setup card

* Allow passing LNURLW to register boltcard

* debug

* debug

* debug

* Only show setup/reset when the page is fully loaded

* Apply suggestions from code review

---------

Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>
2024-03-14 11:07:11 +01:00
c56c6401d6 (feat) monero settlement thresholds ()
* (bug) treat xmr wallet directory as required

The wallet directory configuration setting is required
because the `UIMoneroLikeStoreController`'s
`GetMoneroLikePaymentMethodViewModel` method checks if the wallet file
exists, and to do that in needs the directory.

* (feat) xmr settlement thresholds

Adds the ability to select zero, 1, 10, or a custom number of
confirmations as the payment settlement threshold.

* (review) fix validation message not showing

---------

Co-authored-by: Henry Hollingworth <henry.hollingworth@alcoa.com>
2024-03-14 10:31:27 +01:00
0e64df3bbf Parallel payout ln () 2024-03-14 10:29:14 +01:00
e497903bf4 Support Admin being able to view stores ()
* Support Admin being able to view stores

* fix null check

* Delete obsolete empty view

* Add test

* Apply CanViewStoreSettings policy changes

Taken from 

* Fix Selenium tests

* Update dashboard permission requirement

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2024-03-14 10:25:40 +01:00
f1ff913cbe PoS app to show POS view for easy setup ()
* PoS app to show POS view for easy setup

* update selenium test

* Updates

* Add QR code icon

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2024-03-14 08:52:33 +01:00
3a00d32ce0 Fix flaky 2024-03-13 22:51:55 +09:00
f0f698f411 Fix tests (Fix ) 2024-03-13 22:29:39 +09:00
22c6468a5d Wallet: Label filter dropdown ()
Uses a dropdown component for the label filter, which also work on mobile. Closes .
2024-03-12 10:48:37 +01:00
a60072a431 do not have report name conflict with old plugin ()
* do not have report name conflict with old plugin

* tryadd instead of add

* Apply  to crowdfund too
2024-03-11 14:18:47 +01:00
dcc6f17c9c POS: Fix exception when asking for data with a top up item ()
Fixes .
2024-03-11 11:05:44 +01:00
15ce148b99 Apps: Make app name the default title ()
* Apps: Make app name the default title

Successor of  with a way simpler approach. Allows the user-facing title to be set, but defaults it to the app name instead of "Tea shop".

* Test fixes
2024-03-11 11:04:41 +01:00
3b73d5a5cb bump client version 2024-03-08 19:56:04 +09:00
1fd3054006 Fix: Old payments not showing up in reports () 2024-03-05 16:10:54 +09:00
2db1434929 Fix currency-api link () 2024-03-04 10:10:30 +09:00
a171671fe5 Update .NET 8.0 in vscode launch.json () 2024-03-01 22:07:34 +01:00
9160a1d71e Store Logo: Remove restriction of square dimension ()
As discussed on , there is no need for the store logo to be provided in square dimension. As it populates its own row on the public page layouts, we can remove that restriction and make it adaptable by providing only maximum height and width.
2024-02-29 09:34:28 +01:00
a896560a3c Lightning Setup page fixes ()
* Lightning Setup: Fix missing headline

Fixes .

* Lightning Setup: Fix tab switching UI glitch

Fixes .
2024-02-29 06:56:34 +01:00
e43b4ed540 Onboarding: Invite new users ()
* Server Users: More precise message when inviting users

This lets the admin who invited a new user know whether or not an email has been sent. If the SMTP server hasn't been set up, they need to share the invite link with the user.

* Onboarding: Invite new users

- Separates the user self-registration and invite cases
- Adds invitation email for users created by the admin
- Adds invitation tokens to verify user was invited
- Adds handler action for invite links
- Refactors `UserEventHostedService`

* Remove duplicate status message from views that use the wizard layout

* Auto-approve users created by an admin

* Notify admins via email if a new account requires approval

* Update wording

* Fix update user error

* Fix redirect to email confirmation in invite action

* Fix precondition checks after signup

* Improve admin notification

Send notification only if the user does not require email confirmation or when they confirmed their email address. Rationale: We want to inform admins only about qualified users and not annoy them with bot registrations.

* Allow approval alongside resending confirm email

* Use user email in log messages instead of ID

* Prevent unnecessary notification after email confirmation

* Use ApplicationUser type explicitly

* Fix after rebase

* Refactoring: Do not subclass UserRegisteredEvent
2024-02-28 20:43:18 +09:00
8b446e2791 Reposition the camera scan icon in the wallet > send functionality ()
* Reposition the camera scan icon in the wallet > send functionality

* refactored changes

* Minor adjustments

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2024-02-28 10:41:17 +01:00
d72b0e4cee Merge pull request from NicolasDorier/warnignremov
Remove disclaimer for lightning being in experimentation
2024-02-24 21:33:26 +05:00
22996ea21e UI: Deprecate the custom CSS options ()
* UI: Deprecate the custom CSS options

As discussed with @pavlenex we are deprecating the custom CSS options for particular entities (payemnt request, pull payment, POS, crowdfund) in favor of the store branding approach.

This displays the custom CSS section only if these values are set already.

* Add IDs to html element

This will allow styling of individual entities.
2024-02-23 13:42:00 +01:00
d55770cc16 Admin overview of the stores on the instance ()
* Admin overview of the stores on the instance

POC/Draft for .

* Enable admin to access foreign stores

* Remove stores list link

* UI updates

* Grant admins guest access to foreign stores

* Optimize cookie auth handler

* Test fix

* Revert changes related to StoreRepository.FindStore with isAdmin
2024-02-23 09:51:41 +01:00
5c98ca180a Webhook tests + FIXES + DOCS ()
* webhook tests

* fixes and add docs

* Do not update FormResponse and StoreId in update/create PullPayment

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2024-02-23 09:44:42 +01:00
10bb75ce0e Add debug flaky test statement 2024-02-22 19:08:01 +09:00
b9e3686fcf Fix build warnings and flaky tests ()
* Make checkout v2 selenium tests more robust

* Fix build warnings

* Make payjoin test more robust

* Make LNURL test more robust
2024-02-22 09:38:06 +09:00
4ae1046571 Server Settings: Customize instance name and add contact URL ()
* Server Settings: Customize instance name and add contact URL

- The custom instance name would improve 
- Added contact URL closes 

* Fix custom logo display
2024-02-21 20:54:39 +01:00
147c6c4548 HTML Sanitizer updates ()
* Update HTML sanitizer package

* Remove unused sanitizer from apps

* Allow mailto: links

Fixes .
2024-02-21 20:53:24 +01:00
354338180b Store: Move support URL to Checkout Appearance and improve wording ()
As discussed in the recent design meeting.
2024-02-21 18:50:38 +01:00
5939e19f72 Lightning: Replace user info in server URL when logging ()
* Lightning: Replace user info in server URL when logging

Fixes .

* Fix empty user info case
2024-02-21 14:45:05 +01:00
f72a6df55a Add legacy report () 2024-02-21 14:44:49 +01:00
9c95b98f3a Policies: Cleanup and improvements ()
* Policies: Turn checkboxes into toggles

* Move email policy to server email settings

* Move maintenance policies to server maintenance settings

* Policies: Adjust spacings

* Policies: Remove DisableInstantNotifications setting

* Wording updates

* Move maintenance settings back
2024-02-21 14:43:44 +01:00
55a8ba0905 Adding link to API usage examples in docs. () 2024-02-21 14:42:15 +01:00
04037b3d2d Crowdfund : Add Buyer information / Additional information(forms) like POS ()
* Crowfund : Add Buyer information / Additional information(forms) like POS

* PR 5659 - changes

* Cleanups

* fix perk

* Crowdfund form tests

* Add Selenium test for Crowfund

* Selenium update

* update Selenium

* selenium update

* update selenium

* Test fixes and view improvements

* Cleanups

* do not use hacky form element for form detection

---------

Co-authored-by: nisaba <infos@nisaba.solutions>
Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
Co-authored-by: Kukks <evilkukka@gmail.com>
2024-02-21 14:41:21 +01:00
4943c84655 Invoice: Improve events display ()
Closes .

- Adds seconds to the displayed date and time
- Adds a tooltip that displays the full date and time including milliseconds
- Reintroduced the colored text in case of unusual events/states (this didn't work before)
2024-02-21 14:08:28 +01:00
42a8160768 UI: Make store selector list scrollable if necessary ()
Fixes .
2024-02-21 13:34:47 +01:00
33d3a25928 Apps: Don't redirect .onion requests to canonical domain ()
Fixes .
2024-02-21 13:34:12 +01:00
c2acff81c6 Fix: Labels wouldn't be properly applied to some wallet's transactions () 2024-02-20 18:42:38 +09:00
214d4b0c3f Support 16mb psbts. Potentially fixes 2024-02-19 14:14:41 +01:00
bd4cf61c2b potentially fix 2024-02-19 13:01:08 +01:00
b592ee2fed Bumping LND to 0.17.4-beta ()
* Clarifying that only onchain funds will be restored to the wallet

Off chain recovery would need to be done with channel.backup file which is not part of this process

* Adding powershell version of lncli invoker

* Bumping LND to 0.17.4-beta-rc1

* Bumping LND to 0.17.4-beta
2024-02-16 08:43:51 -06:00
c57e1cca25 Shopify: Improve instruction display () 2024-02-16 09:36:41 +01:00
335f345ce3 Update GeneralSettings.cshtml ()
Removed trailing data
2024-02-13 09:48:16 +01:00
f9a43b537f Remove disclaimer for lightning being in experimentation 2023-12-24 11:12:51 +09:00
324 changed files with 8049 additions and 5404 deletions
.gitignore
.vscode
BTCPayServer.Abstractions
BTCPayServer.Client
BTCPayServer.Common
BTCPayServer.Data
BTCPayServer.PluginPacker
BTCPayServer.Rating
BTCPayServer.Tests
BTCPayServer
BTCPayServer.csproj
Components
MainNav
StoreRecentInvoices
StoreRecentTransactions
StoreSelector
TruncateCenter
Controllers
Data
Events
Extensions
Filters
Forms
HostedServices
Hosting
Models
Payments/Lightning
PayoutProcessors
Plugins
Properties
SearchString.cs
Security
Services
StorePolicies.csUserManagerExtensions.cs
Views
Shared
UIAccount
UIError
UIForms
UIHome
UIInvoice
UILightningAutomatedPayoutProcessors
UIManage
UIMoneroLikeStore
UIOnChainAutomatedPayoutProcessors
UIPaymentRequest
UIPayoutProcessors
UIPullPayment
UIReports
UIServer
UIShopify
UIStorePullPayments
UIStores
UIUserStores
UIWallets
WalletId.cs
wwwroot
Build
Changelog.mdDockerfileREADME.md
docs

1
.gitignore vendored

@ -300,3 +300,4 @@ Plugins/packed
BTCPayServer/wwwroot/swagger/v1/openapi.json
BTCPayServer/appsettings.dev.json
BTCPayServer.Tests/monero_wallet
/BTCPayServer.Tests/NewBlocks.bat

2
.vscode/launch.json vendored

@ -10,7 +10,7 @@
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/BTCPayServer/bin/Debug/net6.0/BTCPayServer.dll",
"program": "${workspaceFolder}/BTCPayServer/bin/Debug/net8.0/BTCPayServer.dll",
"args": [],
"cwd": "${workspaceFolder}/BTCPayServer",
"stopAtEntry": false,

@ -31,10 +31,10 @@
<None Include="icon.png" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="HtmlSanitizer" Version="8.0.723" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
<PackageReference Include="HtmlSanitizer" Version="8.0.838" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.5" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.0-beta.2" />
</ItemGroup>
<ItemGroup>

@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.Options;
using Npgsql;
using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations.Operations;
@ -85,10 +86,9 @@ namespace BTCPayServer.Abstractions.Contracts
{
o.EnableRetryOnFailure(10);
o.SetPostgresVersion(12, 0);
if (!string.IsNullOrEmpty(_schemaPrefix))
{
o.MigrationsHistoryTable(_schemaPrefix);
}
var mainSearchPath = GetSearchPath(_options.Value.ConnectionString);
var schemaPrefix = string.IsNullOrEmpty(_schemaPrefix) ? "__EFMigrationsHistory" : _schemaPrefix;
o.MigrationsHistoryTable(schemaPrefix, mainSearchPath);
})
.ReplaceService<IMigrationsSqlGenerator, CustomNpgsqlMigrationsSqlGenerator>();
break;
@ -108,5 +108,11 @@ namespace BTCPayServer.Abstractions.Contracts
}
}
private string GetSearchPath(string connectionString)
{
var connectionStringBuilder = new NpgsqlConnectionStringBuilder(connectionString);
var searchPaths = connectionStringBuilder.SearchPath?.Split(',');
return searchPaths is not { Length: > 0 } ? null : searchPaths[0];
}
}
}

@ -36,6 +36,17 @@ public static class HttpRequestExtensions
request.Path.ToUriComponent());
}
public static string GetCurrentUrlWithQueryString(this HttpRequest request)
{
return string.Concat(
request.Scheme,
"://",
request.Host.ToUriComponent(),
request.PathBase.ToUriComponent(),
request.Path.ToUriComponent(),
request.QueryString.ToUriComponent());
}
public static string GetCurrentPath(this HttpRequest request)
{
return string.Concat(

@ -101,6 +101,14 @@ namespace BTCPayServer.Abstractions.Extensions
return categoryAndPageMatch && idMatch ? ActivePageClass : null;
}
public static HtmlString ToBrowserDate(this DateTimeOffset date, string netFormat, string jsDateFormat = "short", string jsTimeFormat = "short")
{
var dateTime = date.ToString("o", CultureInfo.InvariantCulture);
var displayDate = date.ToString(netFormat, CultureInfo.InvariantCulture);
var tooltip = dateTime.Replace("T", " ");
return new HtmlString($"<time datetime=\"{dateTime}\" data-date-style=\"{jsDateFormat}\" data-time-style=\"{jsTimeFormat}\" data-initial=\"localized\" data-bs-toggle=\"tooltip\" data-bs-title=\"{tooltip}\">{displayDate}</time>");
}
public static HtmlString ToBrowserDate(this DateTimeOffset date, DateDisplayFormat format = DateDisplayFormat.Localized)
{
var relative = date.ToTimeAgo();

@ -0,0 +1,35 @@
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace BTCPayServer.Abstractions.TagHelpers;
[HtmlTargetElement("form", Attributes = "[permissioned]")]
public partial class PermissionedFormTagHelper(
IAuthorizationService authorizationService,
IHttpContextAccessor httpContextAccessor)
: TagHelper
{
public string Permissioned { get; set; }
public string PermissionResource { get; set; }
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
if (httpContextAccessor.HttpContext is null || string.IsNullOrEmpty(Permissioned))
return;
var res = await authorizationService.AuthorizeAsync(httpContextAccessor.HttpContext.User,
PermissionResource, Permissioned);
if (!res.Succeeded)
{
var content = await output.GetChildContentAsync();
var html = SubmitButtonRegex().Replace(content.GetContent(), "");
output.Content.SetHtmlContent($"<fieldset disabled>{html}</fieldset>");
}
}
[GeneratedRegex("<(button|input).*?type=\"submit\".*?>.*?</\\1>")]
private static partial Regex SubmitButtonRegex();
}

@ -16,7 +16,7 @@
<Platforms>AnyCPU</Platforms>
</PropertyGroup>
<PropertyGroup>
<Version Condition=" '$(Version)' == '' ">1.7.3</Version>
<Version Condition=" '$(Version)' == '' ">1.7.4</Version>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<PublishRepositoryUrl>true</PublishRepositoryUrl>
@ -31,7 +31,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BTCPayServer.Lightning.Common" Version="1.5.1" />
<PackageReference Include="NBitcoin" Version="7.0.34" />
<PackageReference Include="NBitcoin" Version="7.0.37" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>

@ -109,7 +109,7 @@ namespace BTCPayServer.Client
{
var response = await _httpClient.SendAsync(
CreateHttpRequest(
$"/api/v1/pull-payments/{pullPaymentId}/lnurl",
$"api/v1/pull-payments/{HttpUtility.UrlEncode(pullPaymentId)}/lnurl",
method: HttpMethod.Get), cancellationToken);
return await HandleResponse<PullPaymentLNURL>(response);
}

@ -1,4 +1,5 @@
using System;
using System.Globalization;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
@ -58,6 +59,8 @@ namespace BTCPayServer.Client.JsonConverters
return null;
return TimeSpan.Zero;
}
if (reader.TokenType == JsonToken.String && TimeSpan.TryParse(reader.Value?.ToString(), CultureInfo.InvariantCulture, out var res))
return res;
if (reader.TokenType != JsonToken.Integer)
throw new JsonObjectException("Invalid timespan, expected integer", reader);
return ToTimespan((long)reader.Value);

@ -26,6 +26,7 @@ namespace BTCPayServer.Client.Models
public string Template { get; set; } = null;
[JsonConverter(typeof(StringEnumConverter))]
public PosViewType DefaultView { get; set; }
public bool ShowItems { get; set; } = false;
public bool ShowCustomAmount { get; set; } = false;
public bool ShowDiscount { get; set; } = false;
public bool ShowSearch { get; set; } = true;

@ -23,7 +23,5 @@ namespace BTCPayServer.Client.Models
public DateTimeOffset? StartsAt { get; set; }
public string[] PaymentMethods { get; set; }
public bool AutoApproveClaims { get; set; }
public string EmbeddedCSS { get; set; }
public string CustomCSSLink { get; set; }
}
}

@ -1,3 +1,5 @@
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models;
public class EmailSettingsData
@ -26,4 +28,11 @@ public class EmailSettingsData
get; set;
}
public bool DisableCertificateCheck { get; set; }
[JsonIgnore]
public bool EnabledCertificateCheck
{
get => !DisableCertificateCheck;
set { DisableCertificateCheck = !value; }
}
}

@ -1,3 +1,5 @@
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models;
public class LightningAddressData
@ -6,5 +8,5 @@ public class LightningAddressData
public string CurrencyCode { get; set; }
public decimal? Min { get; set; }
public decimal? Max { get; set; }
public JObject InvoiceMetadata { get; set; }
}

@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models
{
@ -13,12 +16,15 @@ namespace BTCPayServer.Client.Models
public bool? Archived { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset Created { get; set; }
}
[JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; } = new Dictionary<string, JToken>();
}
public class PointOfSaleAppData : AppDataBase
{
public string Title { get; set; }
public string DefaultView { get; set; }
public bool ShowItems { get; set; }
public bool ShowCustomAmount { get; set; }
public bool ShowDiscount { get; set; }
public bool ShowSearch { get; set; }

@ -7,11 +7,11 @@ namespace BTCPayServer.Client.Models
{
public class WebhookPayoutEvent : StoreWebhookEvent
{
public WebhookPayoutEvent(string evtType, string storeId)
public WebhookPayoutEvent(string type, string storeId)
{
if (!evtType.StartsWith("payout", StringComparison.InvariantCultureIgnoreCase))
throw new ArgumentException("Invalid event type", nameof(evtType));
Type = evtType;
if (!type.StartsWith("payout", StringComparison.InvariantCultureIgnoreCase))
throw new ArgumentException("Invalid event type", nameof(type));
Type = type;
StoreId = storeId;
}
@ -21,11 +21,11 @@ namespace BTCPayServer.Client.Models
}
public class WebhookPaymentRequestEvent : StoreWebhookEvent
{
public WebhookPaymentRequestEvent(string evtType, string storeId)
public WebhookPaymentRequestEvent(string type, string storeId)
{
if (!evtType.StartsWith("paymentrequest", StringComparison.InvariantCultureIgnoreCase))
throw new ArgumentException("Invalid event type", nameof(evtType));
Type = evtType;
if (!type.StartsWith("paymentrequest", StringComparison.InvariantCultureIgnoreCase))
throw new ArgumentException("Invalid event type", nameof(type));
Type = type;
StoreId = storeId;
}

@ -4,7 +4,7 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="NBXplorer.Client" Version="4.3.0" />
<PackageReference Include="NBXplorer.Client" Version="4.3.1" />
<PackageReference Include="NicolasDorier.StandardConfiguration" Version="2.0.1" />
</ItemGroup>
<ItemGroup Condition="'$(Altcoins)' != 'true'">

@ -107,10 +107,10 @@ namespace BTCPayServer.Data
//PayjoinLock.OnModelCreating(builder);
PaymentRequestData.OnModelCreating(builder, Database);
PaymentData.OnModelCreating(builder, Database);
PayoutData.OnModelCreating(builder);
PayoutData.OnModelCreating(builder, Database);
PendingInvoiceData.OnModelCreating(builder);
//PlannedTransaction.OnModelCreating(builder);
PullPaymentData.OnModelCreating(builder);
PullPaymentData.OnModelCreating(builder, Database);
RefundData.OnModelCreating(builder);
SettingData.OnModelCreating(builder, Database);
StoreSettingData.OnModelCreating(builder, Database);

@ -3,11 +3,11 @@
<Import Project="../Build/Common.csproj" />
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.1">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.7" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj" />

@ -40,7 +40,6 @@ namespace BTCPayServer.Data
{
return Severity switch
{
EventSeverity.Info => "info",
EventSeverity.Error => "danger",
EventSeverity.Success => "success",
EventSeverity.Warning => "warning",

@ -43,7 +43,7 @@ public class LightningAddressDataBlob
public decimal? Max { get; set; }
public JObject InvoiceMetadata { get; set; }
public string PullPaymentId { get; set; }
[JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; }
}

@ -1,8 +1,11 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text;
using BTCPayServer.Client.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NBitcoin;
namespace BTCPayServer.Data
@ -21,14 +24,14 @@ namespace BTCPayServer.Data
[MaxLength(20)]
[Required]
public string PaymentMethodId { get; set; }
public byte[] Blob { get; set; }
public byte[] Proof { get; set; }
public string Blob { get; set; }
public string Proof { get; set; }
#nullable enable
public string? Destination { get; set; }
#nullable restore
public StoreData StoreData { get; set; }
internal static void OnModelCreating(ModelBuilder builder)
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
builder.Entity<PayoutData>()
.HasOne(o => o.PullPaymentData)
@ -43,6 +46,33 @@ namespace BTCPayServer.Data
.HasIndex(o => o.State);
builder.Entity<PayoutData>()
.HasIndex(x => new { DestinationId = x.Destination, x.State });
if (databaseFacade.IsNpgsql())
{
builder.Entity<PayoutData>()
.Property(o => o.Blob)
.HasColumnType("JSONB");
builder.Entity<PayoutData>()
.Property(o => o.Proof)
.HasColumnType("JSONB");
}
else if (databaseFacade.IsMySql())
{
builder.Entity<PayoutData>()
.Property(o => o.Blob)
.HasConversion(new ValueConverter<string, byte[]>
(
convertToProviderExpression: (str) => Encoding.UTF8.GetBytes(str),
convertFromProviderExpression: (bytes) => Encoding.UTF8.GetString(bytes)
));
builder.Entity<PayoutData>()
.Property(o => o.Proof)
.HasConversion(new ValueConverter<string, byte[]>
(
convertToProviderExpression: (str) => Encoding.UTF8.GetBytes(str),
convertFromProviderExpression: (bytes) => Encoding.UTF8.GetString(bytes)
));
}
}
// utility methods

@ -3,8 +3,11 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Text;
using BTCPayServer.Client.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NBitcoin;
namespace BTCPayServer.Data
@ -24,16 +27,33 @@ namespace BTCPayServer.Data
public DateTimeOffset? EndDate { get; set; }
public bool Archived { get; set; }
public List<PayoutData> Payouts { get; set; }
public byte[] Blob { get; set; }
public string Blob { get; set; }
internal static void OnModelCreating(ModelBuilder builder)
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
builder.Entity<PullPaymentData>()
.HasIndex(o => o.StoreId);
builder.Entity<PullPaymentData>()
.HasOne(o => o.StoreData)
.HasOne(o => o.StoreData)
.WithMany(o => o.PullPayments).OnDelete(DeleteBehavior.Cascade);
if (databaseFacade.IsNpgsql())
{
builder.Entity<PullPaymentData>()
.Property(o => o.Blob)
.HasColumnType("JSONB");
}
else if (databaseFacade.IsMySql())
{
builder.Entity<PullPaymentData>()
.Property(o => o.Blob)
.HasConversion(new ValueConverter<string, byte[]>
(
convertToProviderExpression: (str) => Encoding.UTF8.GetBytes(str),
convertFromProviderExpression: (bytes) => Encoding.UTF8.GetString(bytes)
));
}
}
public (DateTimeOffset Start, DateTimeOffset? End)? GetPeriod(DateTimeOffset now)

@ -0,0 +1,26 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240220000000_FixWalletObjectsWithEmptyWalletId")]
public partial class FixWalletObjectsWithEmptyWalletId : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
if (migrationBuilder.IsNpgsql())
{
migrationBuilder.Sql("DELETE FROM \"WalletObjects\" WHERE \"WalletId\"='';");
}
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

@ -0,0 +1,28 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240229000000_PayoutAndPullPaymentToJsonBlob")]
public partial class PayoutAndPullPaymentToJsonBlob : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
if (migrationBuilder.IsNpgsql())
{
migrationBuilder.Sql("ALTER TABLE \"Payouts\" ALTER COLUMN \"Blob\" TYPE JSONB USING regexp_replace(convert_from(\"Blob\",'UTF8'), '\\\\u0000', '', 'g')::JSONB");
migrationBuilder.Sql("ALTER TABLE \"Payouts\" ALTER COLUMN \"Proof\" TYPE JSONB USING regexp_replace(convert_from(\"Proof\",'UTF8'), '\\\\u0000', '', 'g')::JSONB");
migrationBuilder.Sql("ALTER TABLE \"PullPayments\" ALTER COLUMN \"Blob\" TYPE JSONB USING regexp_replace(convert_from(\"Blob\",'UTF8'), '\\\\u0000', '', 'g')::JSONB");
}
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

@ -0,0 +1,98 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Newtonsoft.Json;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240229092905_AddManagerAndEmployeeToStoreRoles")]
public partial class AddManagerAndEmployeeToStoreRoles : Migration
{
object GetPermissionsData(MigrationBuilder migrationBuilder, string[] permissions)
{
return migrationBuilder.IsNpgsql()
? permissions
: JsonConvert.SerializeObject(permissions);
}
protected override void Up(MigrationBuilder migrationBuilder)
{
var permissionsType = migrationBuilder.IsNpgsql() ? "TEXT[]" : "TEXT";
migrationBuilder.InsertData(
"StoreRoles",
columns: new[] { "Id", "Role", "Permissions" },
columnTypes: new[] { "TEXT", "TEXT", permissionsType },
values: new object[,]
{
{
"Manager", "Manager", GetPermissionsData(migrationBuilder, new[]
{
"btcpay.store.canviewstoresettings",
"btcpay.store.canmodifyinvoices",
"btcpay.store.webhooks.canmodifywebhooks",
"btcpay.store.canmodifypaymentrequests",
"btcpay.store.canmanagepullpayments",
"btcpay.store.canmanagepayouts"
})
},
{
"Employee", "Employee", GetPermissionsData(migrationBuilder, new[]
{
"btcpay.store.canmodifyinvoices",
"btcpay.store.canmodifypaymentrequests",
"btcpay.store.cancreatenonapprovedpullpayments",
"btcpay.store.canviewpayouts",
"btcpay.store.canviewpullpayments"
})
}
});
migrationBuilder.UpdateData(
"StoreRoles",
keyColumns: new[] { "Id" },
keyColumnTypes: new[] { "TEXT" },
keyValues: new[] { "Guest" },
columns: new[] { "Permissions" },
columnTypes: new[] { permissionsType },
values: new object[]
{
GetPermissionsData(migrationBuilder, new[]
{
"btcpay.store.canmodifyinvoices",
"btcpay.store.canviewpaymentrequests",
"btcpay.store.canviewpullpayments",
"btcpay.store.canviewpayouts"
})
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData("StoreRoles", "Id", "Manager");
migrationBuilder.DeleteData("StoreRoles", "Id", "Employee");
var permissionsType = migrationBuilder.IsNpgsql() ? "TEXT[]" : "TEXT";
migrationBuilder.UpdateData(
"StoreRoles",
keyColumns: new[] { "Id" },
keyColumnTypes: new[] { "TEXT" },
keyValues: new[] { "Guest" },
columns: new[] { "Permissions" },
columnTypes: new[] { permissionsType },
values: new object[]
{
GetPermissionsData(migrationBuilder, new[]
{
"btcpay.store.canviewstoresettings",
"btcpay.store.canmodifyinvoices",
"btcpay.store.canviewcustodianaccounts",
"btcpay.store.candeposittocustodianaccount"
})
});
}
}
}

@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
@ -16,7 +16,7 @@ namespace BTCPayServer.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.0");
modelBuilder.HasAnnotation("ProductVersion", "8.0.1");
modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b =>
{
@ -599,7 +599,7 @@ namespace BTCPayServer.Migrations
.HasColumnType("TEXT");
b.Property<byte[]>("Blob")
.HasColumnType("BLOB");
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Date")
.HasColumnType("TEXT");
@ -613,7 +613,7 @@ namespace BTCPayServer.Migrations
.HasColumnType("TEXT");
b.Property<byte[]>("Proof")
.HasColumnType("BLOB");
.HasColumnType("TEXT");
b.Property<string>("PullPaymentDataId")
.HasColumnType("TEXT");
@ -704,7 +704,7 @@ namespace BTCPayServer.Migrations
.HasColumnType("INTEGER");
b.Property<byte[]>("Blob")
.HasColumnType("BLOB");
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("EndDate")
.HasColumnType("TEXT");

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
@ -109,9 +110,22 @@ namespace BTCPayServer.PluginPacker
private static Type[] GetAllExtensionTypesFromAssembly(Assembly assembly)
{
return assembly.GetTypes().Where(type =>
return GetLoadableTypes(assembly).Where(type =>
typeof(IBTCPayServerPlugin).IsAssignableFrom(type) &&
!type.IsAbstract).ToArray();
}
static Type[] GetLoadableTypes(Assembly assembly)
{
if (assembly == null)
throw new ArgumentNullException(nameof(assembly));
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException e)
{
return e.Types.Where(t => t != null).ToArray();
}
}
}
}

@ -4,9 +4,9 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="5.2.9" />
<PackageReference Include="NBitcoin" Version="7.0.34" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.10.0" />
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
<PackageReference Include="NBitcoin" Version="7.0.37" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="DigitalRuby.ExchangeSharp" Version="1.0.4" />
</ItemGroup>

@ -9,7 +9,7 @@ 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");
public RateSourceInfo RateSourceInfo => new("free-currency-rates", "Free Currency Rates", "https://currency-api.pages.dev/v1/currencies/btc.min.json");
private readonly HttpClient _httpClient;
public FreeCurrencyRatesRateProvider(HttpClient httpClient)
{

@ -125,7 +125,7 @@ namespace BTCPayServer.Tests
Assert.IsType<RedirectToActionResult>(response);
// Setting it again should show the confirmation page
response = await controller.UpdateWallet(new WalletSetupViewModel {StoreId = storeId, CryptoCode = cryptoCode, DerivationScheme = oldScheme });
response = await controller.UpdateWallet(new WalletSetupViewModel { StoreId = storeId, CryptoCode = cryptoCode, DerivationScheme = oldScheme });
setupVm = (WalletSetupViewModel)Assert.IsType<ViewResult>(response).Model;
Assert.True(setupVm.Confirmation);
@ -133,7 +133,7 @@ namespace BTCPayServer.Tests
// cobo vault file
var content = "{\"ExtPubKey\":\"xpub6CEqRFZ7yZxCFXuEWZBAdnC8bdvu9SRHevaoU2SsW9ZmKhrCShmbpGZWwaR15hdLURf8hg47g4TpPGaqEU8hw5LEJCE35AUhne67XNyFGBk\",\"MasterFingerprint\":\"7a7563b5\",\"DerivationPath\":\"M\\/84'\\/0'\\/0'\",\"CoboVaultFirmwareVersion\":\"1.2.0(BTC-Only)\"}";
response = await controller.UpdateWallet(new WalletSetupViewModel {StoreId = storeId, CryptoCode = cryptoCode, WalletFile = TestUtils.GetFormFile("cobovault.json", content)});
response = await controller.UpdateWallet(new WalletSetupViewModel { StoreId = storeId, CryptoCode = cryptoCode, WalletFile = TestUtils.GetFormFile("cobovault.json", content) });
setupVm = (WalletSetupViewModel)Assert.IsType<ViewResult>(response).Model;
Assert.True(setupVm.Confirmation);
response = await controller.UpdateWallet(setupVm);
@ -144,7 +144,7 @@ namespace BTCPayServer.Tests
// wasabi wallet file
content = "{\r\n \"EncryptedSecret\": \"6PYWBQ1zsukowsnTNA57UUx791aBuJusm7E4egXUmF5WGw3tcdG3cmTL57\",\r\n \"ChainCode\": \"waSIVbn8HaoovoQg/0t8IS1+ZCxGsJRGFT21i06nWnc=\",\r\n \"MasterFingerprint\": \"7a7563b5\",\r\n \"ExtPubKey\": \"xpub6CEqRFZ7yZxCFXuEWZBAdnC8bdvu9SRHevaoU2SsW9ZmKhrCShmbpGZWwaR15hdLURf8hg47g4TpPGaqEU8hw5LEJCE35AUhne67XNyFGBk\",\r\n \"PasswordVerified\": false,\r\n \"MinGapLimit\": 21,\r\n \"AccountKeyPath\": \"84'/0'/0'\",\r\n \"BlockchainState\": {\r\n \"Network\": \"RegTest\",\r\n \"Height\": \"0\"\r\n },\r\n \"HdPubKeys\": []\r\n}";
response = await controller.UpdateWallet(new WalletSetupViewModel {StoreId = storeId, CryptoCode = cryptoCode, WalletFile = TestUtils.GetFormFile("wasabi.json", content)});
response = await controller.UpdateWallet(new WalletSetupViewModel { StoreId = storeId, CryptoCode = cryptoCode, WalletFile = TestUtils.GetFormFile("wasabi.json", content) });
setupVm = (WalletSetupViewModel)Assert.IsType<ViewResult>(response).Model;
Assert.True(setupVm.Confirmation);
response = await controller.UpdateWallet(setupVm);
@ -155,13 +155,13 @@ namespace BTCPayServer.Tests
// Can we upload coldcard settings? (Should fail, we are giving a mainnet file to a testnet network)
content = "{\"keystore\": {\"ckcc_xpub\": \"xpub661MyMwAqRbcGVBsTGeNZN6QGVHmMHLdSA4FteGsRrEriu4pnVZMZWnruFFFXkMnyoBjyHndD3Qwcfz4MPzBUxjSevweNFQx7SAYZATtcDw\", \"xpub\": \"ypub6WWc2gWwHbdnAAyJDnR4SPL1phRh7REqrPBfZeizaQ1EmTshieRXJC3Z5YoU4wkcdKHEjQGkh6AYEzCQC1Kz3DNaWSwdc1pc8416hAjzqyD\", \"label\": \"Coldcard Import 0x60d1af8b\", \"ckcc_xfp\": 1624354699, \"type\": \"hardware\", \"hw_type\": \"coldcard\", \"derivation\": \"m/49'/0'/0'\"}, \"wallet_type\": \"standard\", \"use_encryption\": false, \"seed_version\": 17}";
response = await controller.UpdateWallet(new WalletSetupViewModel {StoreId = storeId, CryptoCode = cryptoCode, WalletFile = TestUtils.GetFormFile("coldcard-ypub.json", content)});
response = await controller.UpdateWallet(new WalletSetupViewModel { StoreId = storeId, CryptoCode = cryptoCode, WalletFile = TestUtils.GetFormFile("coldcard-ypub.json", content) });
setupVm = (WalletSetupViewModel)Assert.IsType<ViewResult>(response).Model;
Assert.False(setupVm.Confirmation); // Should fail, we are giving a mainnet file to a testnet network
// And with a good file? (upub)
content = "{\"keystore\": {\"ckcc_xpub\": \"tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS\", \"xpub\": \"upub5DBYp1qGgsTrkzCptMGZc2x18pquLwGrBw6nS59T4NViZ4cni1mGowQzziy85K8vzkp1jVtWrSkLhqk9KDfvrGeB369wGNYf39kX8rQfiLn\", \"label\": \"Coldcard Import 0x60d1af8b\", \"ckcc_xfp\": 1624354699, \"type\": \"hardware\", \"hw_type\": \"coldcard\", \"derivation\": \"m/49'/0'/0'\"}, \"wallet_type\": \"standard\", \"use_encryption\": false, \"seed_version\": 17}";
response = await controller.UpdateWallet(new WalletSetupViewModel {StoreId = storeId, CryptoCode = cryptoCode, WalletFile = TestUtils.GetFormFile("coldcard-upub.json", content)});
response = await controller.UpdateWallet(new WalletSetupViewModel { StoreId = storeId, CryptoCode = cryptoCode, WalletFile = TestUtils.GetFormFile("coldcard-upub.json", content) });
setupVm = (WalletSetupViewModel)Assert.IsType<ViewResult>(response).Model;
Assert.True(setupVm.Confirmation);
response = await controller.UpdateWallet(setupVm);
@ -430,6 +430,7 @@ namespace BTCPayServer.Tests
[Fact(Timeout = TestTimeout)]
[Trait("Altcoins", "Altcoins")]
[Trait("Lightning", "Lightning")]
[Trait("Integration", "Integration")]
public async Task CanUsePaymentMethodDropdown()
{
using (var s = CreateSeleniumTester())
@ -438,10 +439,10 @@ namespace BTCPayServer.Tests
s.Server.ActivateLightning();
await s.StartAsync();
s.GoToRegister();
s.RegisterNewUser();
s.RegisterNewUser(true);
s.CreateNewStore();
s.AddDerivationScheme("BTC");
s.EnableCheckout(Client.Models.CheckoutType.V1);
//check that there is no dropdown since only one payment method is set
var invoiceId = s.CreateInvoice(10, "USD", "a@g.com");
s.GoToInvoiceCheckout(invoiceId);
@ -454,18 +455,25 @@ namespace BTCPayServer.Tests
invoiceId = s.CreateInvoice(10, "USD", "a@g.com");
s.GoToInvoiceCheckout(invoiceId);
var currencyDropdownButton = s.Driver.FindElement(By.ClassName("payment__currencies"));
Assert.Contains("BTC", currencyDropdownButton.Text);
Assert.Contains("Bitcoin", currencyDropdownButton.Text);
currencyDropdownButton.Click();
var elements = s.Driver.FindElement(By.ClassName("vex-content")).FindElements(By.ClassName("vexmenuitem"));
Assert.Equal(3, elements.Count);
elements.Single(element => element.Text.Contains("LTC")).Click();
IEnumerable<IWebElement> elements = null;
TestUtils.Eventually(() =>
{
elements = s.Driver.FindElement(By.ClassName("vex-content")).FindElements(By.ClassName("vexmenuitem"));
Assert.Equal(3, elements.Count());
elements.Single(element => element.Text.Contains("Litecoin")).Click();
});
currencyDropdownButton = s.Driver.FindElement(By.ClassName("payment__currencies"));
Assert.Contains("LTC", currencyDropdownButton.Text);
Assert.Contains("Litecoin", currencyDropdownButton.Text);
currencyDropdownButton.Click();
elements = s.Driver.FindElement(By.ClassName("vex-content")).FindElements(By.ClassName("vexmenuitem"));
elements.Single(element => element.Text.Contains("Lightning")).Click();
TestUtils.Eventually(() =>
{
elements = s.Driver.FindElement(By.ClassName("vex-content")).FindElements(By.ClassName("vexmenuitem"));
elements.Single(element => element.Text.Contains("Lightning")).Click();
});
currencyDropdownButton = s.Driver.FindElement(By.ClassName("payment__currencies"));
Assert.Contains("Lightning", currencyDropdownButton.Text);
@ -754,7 +762,7 @@ inventoryitem:
inventory: 1
noninventoryitem:
price: 10.0";
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
@ -866,7 +874,7 @@ g:
Assert.Contains(items, item => item.Id == "e" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum);
Assert.Contains(items, item => item.Id == "f" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup);
Assert.Contains(items, item => item.Id == "g" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup);
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Static, choiceKey: "g").Result);
invoices = user.BitPay.GetInvoices();

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<Import Project="../Build/Common.csproj" />
<PropertyGroup>
<NoWarn>$(NoWarn),xUnit1031</NoWarn>
@ -23,8 +23,8 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.15" />
<PackageReference Include="Selenium.Support" Version="4.1.1" />
<PackageReference Include="Selenium.WebDriver" Version="4.1.1" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="121.0.6167.8500" />
<PackageReference Include="Selenium.WebDriver" Version="4.22.0" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="125.0.6422.14100" />
<PackageReference Include="xunit" Version="2.6.6" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
<PrivateAssets>all</PrivateAssets>

@ -39,7 +39,6 @@ namespace BTCPayServer.Tests
var supportUrl = "https://support.satoshisteaks.com/{InvoiceId}/";
s.GoToStore();
s.Driver.FindElement(By.Id("StoreWebsite")).SendKeys(storeUrl);
s.Driver.FindElement(By.Id("StoreSupportUrl")).SendKeys(supportUrl);
s.Driver.FindElement(By.Id("Save")).Click();
Assert.Contains("Store successfully updated", s.FindAlertMessage().Text);
@ -47,6 +46,7 @@ namespace BTCPayServer.Tests
s.Driver.WaitForAndClick(By.Id("Presets"));
s.Driver.WaitForAndClick(By.Id("Presets_InStore"));
Assert.True(s.Driver.SetCheckbox(By.Id("ShowPayInWalletButton"), true));
s.Driver.FindElement(By.Id("SupportUrl")).SendKeys(supportUrl);
s.Driver.FindElement(By.Id("Save")).SendKeys(Keys.Enter);
Assert.Contains("Store successfully updated", s.FindAlertMessage().Text);
@ -130,9 +130,12 @@ namespace BTCPayServer.Tests
expirySeconds.SendKeys("3");
s.Driver.FindElement(By.Id("Expire")).Click();
var paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
Assert.Contains("This invoice will expire in", paymentInfo.Text);
Assert.DoesNotContain("Please send", paymentInfo.Text);
TestUtils.Eventually(() =>
{
var paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
Assert.Contains("This invoice will expire in", paymentInfo.Text);
Assert.DoesNotContain("Please send", paymentInfo.Text);
});
TestUtils.Eventually(() =>
{
var expiredSection = s.Driver.FindElement(By.Id("unpaid"));
@ -140,7 +143,6 @@ namespace BTCPayServer.Tests
Assert.Contains("Invoice Expired", expiredSection.Text);
Assert.Contains("resubmit a payment", expiredSection.Text);
Assert.DoesNotContain("This invoice expired with partial payment", expiredSection.Text);
});
Assert.True(s.Driver.ElementDoesNotExist(By.Id("ContactLink")));
Assert.True(s.Driver.ElementDoesNotExist(By.Id("ReceiptLink")));
@ -164,9 +166,12 @@ namespace BTCPayServer.Tests
expirySeconds.SendKeys("3");
s.Driver.FindElement(By.Id("Expire")).Click();
paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
Assert.Contains("The invoice hasn't been paid in full.", paymentInfo.Text);
Assert.Contains("Please send", paymentInfo.Text);
TestUtils.Eventually(() =>
{
var paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
Assert.Contains("The invoice hasn't been paid in full.", paymentInfo.Text);
Assert.Contains("Please send", paymentInfo.Text);
});
TestUtils.Eventually(() =>
{
var expiredSection = s.Driver.FindElement(By.Id("unpaid"));
@ -210,7 +215,7 @@ namespace BTCPayServer.Tests
{
s.Driver.FindElement(By.Id("FakePayment")).Click();
s.Driver.FindElement(By.Id("mine-block")).Click();
paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
var paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
Assert.Contains("The invoice hasn't been paid in full", paymentInfo.Text);
Assert.Contains("Please send", paymentInfo.Text);
});
@ -359,10 +364,9 @@ namespace BTCPayServer.Tests
expirySeconds.Clear();
expirySeconds.SendKeys("5");
s.Driver.FindElement(By.Id("Expire")).Click();
TestUtils.Eventually(() =>
{
paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
var paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
Assert.Contains("This invoice will expire in", paymentInfo.Text);
Assert.Contains("00:0", paymentInfo.Text);
Assert.DoesNotContain("Please send", paymentInfo.Text);
@ -382,7 +386,7 @@ namespace BTCPayServer.Tests
s.GoToInvoiceCheckout(invoiceId);
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
paymentInfo = s.Driver.FindElement(By.Id("PaymentInfo"));
var paymentInfo = s.Driver.FindElement(By.Id("PaymentInfo"));
Assert.False(paymentInfo.Displayed);
Assert.DoesNotContain("This invoice will expire in", paymentInfo.Text);
@ -390,11 +394,13 @@ namespace BTCPayServer.Tests
expirySeconds.Clear();
expirySeconds.SendKeys("599");
s.Driver.FindElement(By.Id("Expire")).Click();
paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
Assert.True(paymentInfo.Displayed);
Assert.Contains("This invoice will expire in", paymentInfo.Text);
Assert.Contains("09:5", paymentInfo.Text);
TestUtils.Eventually(() =>
{
paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
Assert.True(paymentInfo.Displayed);
Assert.Contains("This invoice will expire in", paymentInfo.Text);
Assert.Contains("09:5", paymentInfo.Text);
});
// Disable LNURL again
s.GoToHome();
@ -460,13 +466,12 @@ namespace BTCPayServer.Tests
.GetPaymentMethodDetails().GetPaymentDestination(), Network.RegTest),
new Money(0.001m, MoneyUnit.BTC));
IWebElement closebutton = null;
TestUtils.Eventually(() =>
{
closebutton = iframe.FindElement(By.Id("close"));
Assert.True(closebutton.Displayed);
var closeButton = iframe.FindElement(By.Id("close"));
Assert.True(closeButton.Displayed);
closeButton.Click();
});
closebutton.Click();
s.Driver.AssertElementNotFound(By.Name("btcpay"));
Assert.Equal(s.Driver.Url,
new Uri(s.ServerUri, $"tests/index.html?invoice={invoiceId}").ToString());

@ -1,15 +1,19 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Form;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Forms;
using BTCPayServer.Forms.Models;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Plugins.Crowdfund;
using BTCPayServer.Plugins.Crowdfund.Controllers;
using BTCPayServer.Plugins.Crowdfund.Models;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBitpayClient;
@ -303,5 +307,114 @@ namespace BTCPayServer.Tests
Assert.Equal(0.7m, model.Info.CurrentPendingAmount);
});
}
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]
public async Task CrowdfundWithFormNoPerk()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
await user.GrantAccessAsync();
user.RegisterDerivationScheme("BTC");
await user.SetNetworkFeeMode(NetworkFeeMode.Never);
var frmService = tester.PayTester.GetService<FormDataService>();
var appService = tester.PayTester.GetService<AppService>();
var crowdfund = user.GetController<UICrowdfundController>();
var apps = user.GetController<UIAppsController>();
var appData = new AppData { StoreDataId = user.StoreId, Name = "test", AppType = CrowdfundAppType.AppType };
await appService.UpdateOrCreateApp(appData);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
var app = appList.Apps[0];
apps.HttpContext.SetAppData(appData);
crowdfund.HttpContext.SetAppData(appData);
var form = new Form
{
Fields =
[
Field.Create("Enter your email", "item1", "test@toto.com", true, null, "email"),
Field.Create("Name", "item2", 2.ToString(), true, null),
Field.Create("Item3", "invoice_item3", 3.ToString(), true, null)
]
};
var frmData = new FormData
{
StoreId = user.StoreId,
Name = "frmTest",
Config = form.ToString()
};
await frmService.AddOrUpdateForm(frmData);
var lstForms = await frmService.GetForms(user.StoreId);
Assert.NotEmpty(lstForms);
var crowdfundViewModel = await crowdfund.UpdateCrowdfund(app.Id).AssertViewModelAsync<UpdateCrowdfundViewModel>();
crowdfundViewModel.FormId = lstForms[0].Id;
crowdfundViewModel.TargetCurrency = "BTC";
crowdfundViewModel.Enabled = true;
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel, "save").Result);
var vm2 = await crowdfund.CrowdfundForm(app.Id, (decimal?)0.01).AssertViewModelAsync<FormViewModel>();
var res = await crowdfund.CrowdfundFormSubmit(app.Id, (decimal)0.01, "", vm2);
Assert.IsNotType<NotFoundObjectResult>(res);
Assert.IsNotType<BadRequest>(res);
}
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]
public async Task CrowdfundWithFormAndPerk()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
await user.GrantAccessAsync();
user.RegisterDerivationScheme("BTC");
await user.SetNetworkFeeMode(NetworkFeeMode.Never);
var frmService = tester.PayTester.GetService<FormDataService>();
var appService = tester.PayTester.GetService<AppService>();
var crowdfund = user.GetController<UICrowdfundController>();
var apps = user.GetController<UIAppsController>();
var appData = new AppData { StoreDataId = user.StoreId, Name = "test", AppType = CrowdfundAppType.AppType };
await appService.UpdateOrCreateApp(appData);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
var app = appList.Apps[0];
apps.HttpContext.SetAppData(appData);
crowdfund.HttpContext.SetAppData(appData);
var form = new Form
{
Fields =
[
Field.Create("Enter your email", "item1", "test@toto.com", true, null, "email"),
Field.Create("Name", "item2", 2.ToString(), true, null),
Field.Create("Item3", "invoice_item3", 3.ToString(), true, null)
]
};
var frmData = new FormData
{
StoreId = user.StoreId,
Name = "frmTest",
Config = form.ToString()
};
await frmService.AddOrUpdateForm(frmData);
var lstForms = await frmService.GetForms(user.StoreId);
Assert.NotEmpty(lstForms);
var crowdfundViewModel = await crowdfund.UpdateCrowdfund(app.Id).AssertViewModelAsync<UpdateCrowdfundViewModel>();
crowdfundViewModel.FormId = lstForms[0].Id;
crowdfundViewModel.TargetCurrency = "BTC";
crowdfundViewModel.Enabled = true;
crowdfundViewModel.PerksTemplate = "[{\"id\": \"xxx\",\"title\": \"Perk 1\",\"priceType\": \"Fixed\",\"price\": \"0.001\",\"image\": \"\",\"description\": \"\",\"categories\": [],\"disabled\": false}]";
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel, "save").Result);
var vm2 = await crowdfund.CrowdfundForm(app.Id, (decimal?)0.01, "xxx").AssertViewModelAsync<FormViewModel>();
var res = await crowdfund.CrowdfundFormSubmit(app.Id, (decimal)0.01, "xxx", vm2);
Assert.IsNotType<NotFoundObjectResult>(res);
Assert.IsNotType<BadRequest>(res);
}
}
}

@ -47,7 +47,6 @@ using NBXplorer.DerivationStrategy;
using NBXplorer.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OpenQA.Selenium.DevTools.V100.DOMSnapshot;
using Xunit;
using Xunit.Abstractions;
using StoreData = BTCPayServer.Data.StoreData;
@ -1347,17 +1346,22 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
filter = "status:abed, status:abed2";
search = new SearchString(filter);
Assert.Equal("", search.TextSearch);
Assert.Null(search.TextSearch);
Assert.Null(search.TextFilters);
Assert.Equal("status:abed, status:abed2", search.ToString());
Assert.Throws<KeyNotFoundException>(() => search.Filters["test"]);
Assert.Equal(2, search.Filters["status"].Count);
Assert.Equal("abed", search.Filters["status"].First());
Assert.Equal("abed2", search.Filters["status"].Skip(1).First());
filter = "StartDate:2019-04-25 01:00 AM, hekki";
filter = "StartDate:2019-04-25 01:00 AM, hekki,orderid:MYORDERID,orderid:MYORDERID_2";
search = new SearchString(filter);
Assert.Equal("2019-04-25 01:00 AM", search.Filters["startdate"].First());
Assert.Equal("hekki", search.TextSearch);
Assert.Equal("orderid:MYORDERID,orderid:MYORDERID_2", search.TextFilters);
Assert.Equal("orderid:MYORDERID,orderid:MYORDERID_2,hekki", search.TextCombined);
Assert.Equal("StartDate:2019-04-25 01:00 AM", search.WithoutSearchText());
Assert.Equal(filter, search.ToString());
// modify search
filter = $"status:settled,exceptionstatus:paidLate,unusual:true, fulltext searchterm, storeid:{storeId},startdate:2019-04-25 01:00:00";

@ -303,6 +303,16 @@ namespace BTCPayServer.Tests
Assert.Equal("test app title", app.Title);
Assert.False(app.Archived);
// Test title falls back to name
app = await client.CreatePointOfSaleApp(
user.StoreId,
new CreatePointOfSaleAppRequest
{
AppName = "test app name"
}
);
Assert.Equal("test app name", app.Title);
// Make sure we return a 404 if we try to get an app that doesn't exist
await AssertHttpError(404, async () =>
{
@ -472,6 +482,16 @@ namespace BTCPayServer.Tests
Assert.Equal("Crowdfund", app.AppType);
Assert.False(app.Archived);
// Test title falls back to name
app = await client.CreateCrowdfundApp(
user.StoreId,
new CreateCrowdfundAppRequest
{
AppName = "test app name"
}
);
Assert.Equal("test app name", app.Title);
// Make sure we return a 404 if we try to get an app that doesn't exist
await AssertHttpError(404, async () =>
{
@ -2140,6 +2160,17 @@ namespace BTCPayServer.Tests
Assert.Equal("BTC", pp.Currency);
Assert.True(pp.AutoApproveClaims);
Assert.Equal(0.79m, pp.Amount);
// If an invoice doesn't have payment because it has been marked as paid, we should still be able to refund it.
invoice = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest { Amount = 5000.0m, Currency = "USD" });
await client.MarkInvoiceStatus(user.StoreId, invoice.Id, new MarkInvoiceStatusRequest { Status = InvoiceStatus.Settled });
var refund = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest
{
PaymentMethod = method.PaymentMethod,
RefundVariant = RefundVariant.CurrentRate
});
Assert.Equal(1.0m, refund.Amount);
Assert.Equal("BTC", refund.Currency);
}
[Fact(Timeout = TestTimeout)]
@ -2318,7 +2349,7 @@ namespace BTCPayServer.Tests
Assert.Equal("updated", invoice.Metadata["itemCode"].Value<string>());
Assert.Equal(15, ((JArray)invoice.Metadata["newstuff"]).Values<int>().Sum());
//also test the the metadata actually got saved
//also test the metadata actually got saved
invoice = await client.GetInvoice(user.StoreId, invoice.Id);
Assert.Equal(newOrderId, invoice.Metadata["orderId"].Value<string>());
Assert.Equal("updated", invoice.Metadata["itemCode"].Value<string>());
@ -2362,7 +2393,7 @@ namespace BTCPayServer.Tests
if (marked == InvoiceStatus.Settled)
{
Assert.Equal(InvoiceStatus.Settled, result.Status);
user.AssertHasWebhookEvent<WebhookInvoiceSettledEvent>(WebhookEventType.InvoiceSettled,
await user.AssertHasWebhookEvent<WebhookInvoiceSettledEvent>(WebhookEventType.InvoiceSettled,
o =>
{
Assert.Equal(inv.Id, o.InvoiceId);
@ -2372,7 +2403,7 @@ namespace BTCPayServer.Tests
if (marked == InvoiceStatus.Invalid)
{
Assert.Equal(InvoiceStatus.Invalid, result.Status);
var evt = user.AssertHasWebhookEvent<WebhookInvoiceInvalidEvent>(WebhookEventType.InvoiceInvalid,
var evt = await user.AssertHasWebhookEvent<WebhookInvoiceInvalidEvent>(WebhookEventType.InvoiceInvalid,
o =>
{
Assert.Equal(inv.Id, o.InvoiceId);
@ -3437,6 +3468,7 @@ namespace BTCPayServer.Tests
var store2 = (await adminClient.CreateStore(new CreateStoreRequest() { Name = "test2" })).Id;
var address1 = Guid.NewGuid().ToString("n").Substring(0, 8);
var address2 = Guid.NewGuid().ToString("n").Substring(0, 8);
var address3 = Guid.NewGuid().ToString("n").Substring(0, 8);
Assert.Empty(await adminClient.GetStoreLightningAddresses(store.Id));
Assert.Empty(await adminClient.GetStoreLightningAddresses(store2));
@ -3464,13 +3496,23 @@ namespace BTCPayServer.Tests
await adminClient.RemoveStoreLightningAddress(store2, address2);
Assert.Empty(await adminClient.GetStoreLightningAddresses(store2));
var store3 = (await adminClient.CreateStore(new CreateStoreRequest { Name = "test3" })).Id;
Assert.Empty(await adminClient.GetStoreLightningAddresses(store3));
var metadata = JObject.FromObject(new { test = 123 });
await adminClient.AddOrUpdateStoreLightningAddress(store3, address3, new LightningAddressData
{
InvoiceMetadata = metadata
});
var lnAddresses = await adminClient.GetStoreLightningAddresses(store3);
Assert.Single(lnAddresses);
Assert.Equal(metadata, lnAddresses[0].InvoiceMetadata);
}
[Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")]
public async Task StoreUsersAPITest()
{
using var tester = CreateServerTester();
await tester.StartAsync();
@ -3480,52 +3522,83 @@ namespace BTCPayServer.Tests
var client = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings);
var roles = await client.GetServerRoles();
Assert.Equal(2,roles.Count);
Assert.Equal(4, roles.Count);
#pragma warning disable CS0618
var ownerRole = roles.Single(data => data.Role == StoreRoles.Owner);
var managerRole = roles.Single(data => data.Role == StoreRoles.Manager);
var employeeRole = roles.Single(data => data.Role == StoreRoles.Employee);
var guestRole = roles.Single(data => data.Role == StoreRoles.Guest);
#pragma warning restore CS0618
var users = await client.GetStoreUsers(user.StoreId);
var storeuser = Assert.Single(users);
Assert.Equal(user.UserId, storeuser.UserId);
Assert.Equal(ownerRole.Id, storeuser.Role);
var user2 = tester.NewAccount();
await user2.GrantAccessAsync(false);
var storeUser = Assert.Single(users);
Assert.Equal(user.UserId, storeUser.UserId);
Assert.Equal(ownerRole.Id, storeUser.Role);
var manager = tester.NewAccount();
await manager.GrantAccessAsync();
var employee = tester.NewAccount();
await employee.GrantAccessAsync();
var guest = tester.NewAccount();
await guest.GrantAccessAsync();
var user2Client = await user2.CreateClient(Policies.CanModifyStoreSettings);
var managerClient = await manager.CreateClient(Policies.CanModifyStoreSettings);
var employeeClient = await employee.CreateClient(Policies.CanModifyStoreSettings);
var guestClient = await guest.CreateClient(Policies.CanModifyStoreSettings);
//test no access to api when unrelated to store at all
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.RemoveStoreUser(user.StoreId, user.UserId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await managerClient.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await managerClient.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.RemoveStoreUser(user.StoreId, user.UserId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.RemoveStoreUser(user.StoreId, user.UserId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await guestClient.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await guestClient.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.RemoveStoreUser(user.StoreId, user.UserId));
await client.AddStoreUser(user.StoreId, new StoreUserData() { Role = guestRole.Id, UserId = user2.UserId });
// add users to store
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = managerRole.Id, UserId = manager.UserId });
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = employeeRole.Id, UserId = employee.UserId });
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = guestRole.Id, UserId = guest.UserId });
//test no access to api when only a guest
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.RemoveStoreUser(user.StoreId, user.UserId));
//test no access to api for employee
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.RemoveStoreUser(user.StoreId, user.UserId));
//test no access to api for guest
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await guestClient.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await guestClient.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.RemoveStoreUser(user.StoreId, user.UserId));
//test access to api for manager
await managerClient.GetStore(user.StoreId);
await managerClient.GetStoreUsers(user.StoreId);
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.RemoveStoreUser(user.StoreId, user.UserId));
await user2Client.GetStore(user.StoreId);
// updates
await client.RemoveStoreUser(user.StoreId, employee.UserId);
await AssertHttpError(403, async () => await employeeClient.GetStore(user.StoreId));
await client.RemoveStoreUser(user.StoreId, user2.UserId);
await AssertHttpError(403, async () =>
await user2Client.GetStore(user.StoreId));
await client.AddStoreUser(user.StoreId, new StoreUserData() { Role = ownerRole.Id, UserId = user2.UserId });
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = ownerRole.Id, UserId = employee.UserId });
await AssertAPIError("duplicate-store-user-role", async () =>
await client.AddStoreUser(user.StoreId,
new StoreUserData() { Role = ownerRole.Id, UserId = user2.UserId }));
await user2Client.RemoveStoreUser(user.StoreId, user.UserId);
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = ownerRole.Id, UserId = employee.UserId }));
await employeeClient.RemoveStoreUser(user.StoreId, user.UserId);
//test no access to api when unrelated to store at all
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await client.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await client.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.RemoveStoreUser(user.StoreId, user.UserId));
await AssertAPIError("store-user-role-orphaned", async () => await user2Client.RemoveStoreUser(user.StoreId, user2.UserId));
await AssertAPIError("store-user-role-orphaned", async () => await employeeClient.RemoveStoreUser(user.StoreId, employee.UserId));
}
[Fact(Timeout = 60 * 2 * 1000)]
@ -3907,8 +3980,9 @@ namespace BTCPayServer.Tests
Assert.True( settings.ProcessNewPayoutsInstantly);
var pluginHookService = tester.PayTester.GetService<IPluginHookService>();
var beforeHookTcs = new TaskCompletionSource();
var afterHookTcs = new TaskCompletionSource();
var beforeHookTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var afterHookTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
TestLogs.LogInformation("Adding hook...");
pluginHookService.ActionInvoked += (sender, tuple) =>
{
switch (tuple.hook)
@ -3939,7 +4013,9 @@ namespace BTCPayServer.Tests
PaymentMethod = "BTC",
Destination = (await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address,
});
TestLogs.LogInformation("Waiting before hook...");
await beforeHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
TestLogs.LogInformation("Waiting before after...");
await afterHookTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
payouts = await adminClient.GetStorePayouts(admin.StoreId);
try

@ -2,6 +2,7 @@ using System;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.BIP78.Sender;
@ -311,7 +312,7 @@ namespace BTCPayServer.Tests
//payjoin is enabled by default.
var invoiceId = s.CreateInvoice(receiver.storeId);
s.GoToInvoiceCheckout(invoiceId);
var bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
var bip21 = s.Driver.WaitForElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
.GetAttribute("href");
Assert.Contains($"{PayjoinClient.BIP21EndpointKey}=", bip21);
@ -327,7 +328,7 @@ namespace BTCPayServer.Tests
invoiceId = s.CreateInvoice(receiver.storeId);
s.GoToInvoiceCheckout(invoiceId);
bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
bip21 = s.Driver.WaitForElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
.GetAttribute("href");
Assert.Contains($"{PayjoinClient.BIP21EndpointKey}=", bip21);
@ -361,7 +362,7 @@ namespace BTCPayServer.Tests
//let's do it all again, except now the receiver has funds and is able to payjoin
invoiceId = s.CreateInvoice();
s.GoToInvoiceCheckout(invoiceId);
bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
bip21 = s.Driver.WaitForElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
.GetAttribute("href");
Assert.Contains($"{PayjoinClient.BIP21EndpointKey}", bip21);
@ -414,13 +415,13 @@ namespace BTCPayServer.Tests
Assert.False(paymentValueRowColumn.Text.Contains("payjoin",
StringComparison.InvariantCultureIgnoreCase));
s.GoToWallet(receiverWalletId, WalletsNavPages.Transactions);
s.Driver.WaitForElement(By.CssSelector("#WalletTransactionsList tr"));
TestUtils.Eventually(() =>
{
s.GoToWallet(receiverWalletId, WalletsNavPages.Transactions);
Assert.Contains(invoiceId, s.Driver.PageSource);
Assert.Contains("payjoin", s.Driver.PageSource);
//this label does not always show since input gets used
// Assert.Contains("payjoin-exposed", s.Driver.PageSource);
// Either the invoice id or the payjoin-exposed label, depending on the input having been used
Assert.Matches(new Regex($"({invoiceId}|payjoin-exposed)"), s.Driver.PageSource);
});
}
}

@ -18,7 +18,6 @@ using NBitcoin;
using NBitcoin.RPC;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Support.Extensions;
using OpenQA.Selenium.Support.UI;
using Xunit;
@ -91,7 +90,6 @@ namespace BTCPayServer.Tests
public void PayInvoice(bool mine = false, decimal? amount = null)
{
if (amount is not null)
{
Driver.FindElement(By.Id("test-payment-amount")).Clear();
@ -99,12 +97,12 @@ namespace BTCPayServer.Tests
}
Driver.WaitUntilAvailable(By.Id("FakePayment"));
Driver.FindElement(By.Id("FakePayment")).Click();
TestUtils.Eventually(() =>
{
Driver.WaitForElement(By.Id("CheatSuccessMessage"));
});
if (mine)
{
TestUtils.Eventually(() =>
{
Driver.WaitForElement(By.Id("CheatSuccessMessage"));
});
MineBlockOnInvoiceCheckout();
}
}
@ -409,15 +407,12 @@ namespace BTCPayServer.Tests
public void Logout()
{
if (!Driver.PageSource.Contains("id=\"Nav-Logout\""))
{
Driver.Navigate().GoToUrl(ServerUri);
}
if (!Driver.PageSource.Contains("id=\"Nav-Logout\"")) GoToUrl("/account");
Driver.FindElement(By.Id("Nav-Account")).Click();
Driver.FindElement(By.Id("Nav-Logout")).Click();
}
public void LogIn(string user, string password)
public void LogIn(string user, string password = "123456")
{
Driver.FindElement(By.Id("Email")).SendKeys(user);
Driver.FindElement(By.Id("Password")).SendKeys(password);
@ -646,5 +641,38 @@ retry:
Driver.FindElement(By.Id($"SectionNav-{navPages}")).Click();
}
}
public void AddUserToStore(string storeId, string email, string role)
{
if (Driver.FindElements(By.Id("AddUser")).Count == 0)
{
GoToStore(storeId, StoreNavPages.Users);
}
Driver.FindElement(By.Id("Email")).SendKeys(email);
new SelectElement(Driver.FindElement(By.Id("Role"))).SelectByValue(role);
Driver.FindElement(By.Id("AddUser")).Click();
Assert.Contains("User added successfully", FindAlertMessage().Text);
}
public void AssertPageAccess(bool shouldHaveAccess, string url)
{
GoToUrl(url);
Assert.DoesNotMatch("404 - Page not found</h", Driver.PageSource);
if (shouldHaveAccess)
Assert.DoesNotMatch("- Denied</h", Driver.PageSource);
else
Assert.Contains("- Denied</h", Driver.PageSource);
}
public (string appName, string appId) CreateApp(string type, string name = null)
{
if (string.IsNullOrEmpty(name)) name = $"{type}-{Guid.NewGuid().ToString()[..14]}";
Driver.FindElement(By.Id($"StoreNav-Create{type}")).Click();
Driver.FindElement(By.Name("AppName")).SendKeys(name);
Driver.FindElement(By.Id("Create")).Click();
Assert.Contains("App successfully created", FindAlertMessage().Text);
var appId = Driver.Url.Split('/')[4];
return (name, appId);
}
}
}

File diff suppressed because it is too large Load Diff

@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
@ -25,6 +27,7 @@ using BTCPayServer.Tests.Logging;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.CodeAnalysis.Operations;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.Payment;
using NBitpayClient;
@ -32,6 +35,7 @@ using NBXplorer.DerivationStrategy;
using NBXplorer.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Npgsql;
using Xunit;
using Xunit.Sdk;
@ -452,9 +456,9 @@ namespace BTCPayServer.Tests
{
private Client.Models.StoreWebhookData _wh;
private FakeServer _server;
private readonly List<WebhookInvoiceEvent> _webhookEvents;
private readonly List<StoreWebhookEvent> _webhookEvents;
private CancellationTokenSource _cts;
public WebhookListener(Client.Models.StoreWebhookData wh, FakeServer server, List<WebhookInvoiceEvent> webhookEvents)
public WebhookListener(Client.Models.StoreWebhookData wh, FakeServer server, List<StoreWebhookEvent> webhookEvents)
{
_wh = wh;
_server = server;
@ -472,7 +476,7 @@ namespace BTCPayServer.Tests
var callback = Encoding.UTF8.GetString(bytes);
lock (_webhookEvents)
{
_webhookEvents.Add(JsonConvert.DeserializeObject<WebhookInvoiceEvent>(callback));
_webhookEvents.Add(JsonConvert.DeserializeObject<DummyStoreWebhookEvent>(callback));
}
req.Response.StatusCode = 200;
_server.Done();
@ -485,8 +489,13 @@ namespace BTCPayServer.Tests
}
}
public List<WebhookInvoiceEvent> WebhookEvents { get; set; } = new List<WebhookInvoiceEvent>();
public TEvent AssertHasWebhookEvent<TEvent>(string eventType, Action<TEvent> assert) where TEvent : class
public class DummyStoreWebhookEvent : StoreWebhookEvent
{
}
public List<StoreWebhookEvent> WebhookEvents { get; set; } = new List<StoreWebhookEvent>();
public async Task<TEvent> AssertHasWebhookEvent<TEvent>(string eventType, Action<TEvent> assert) where TEvent : class
{
int retry = 0;
retry:
@ -510,7 +519,7 @@ retry:
}
if (retry < 3)
{
Thread.Sleep(1000);
await Task.Delay(1000);
retry++;
goto retry;
}
@ -545,13 +554,23 @@ retry:
public async Task AddGuest(string userId)
{
var repo = this.parent.PayTester.GetService<StoreRepository>();
await repo.AddStoreUser(StoreId, userId, StoreRoleId.Guest);
var repo = parent.PayTester.GetService<StoreRepository>();
await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Guest);
}
public async Task AddOwner(string userId)
{
var repo = this.parent.PayTester.GetService<StoreRepository>();
await repo.AddStoreUser(StoreId, userId, StoreRoleId.Owner);
var repo = parent.PayTester.GetService<StoreRepository>();
await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Owner);
}
public async Task AddManager(string userId)
{
var repo = parent.PayTester.GetService<StoreRepository>();
await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Manager);
}
public async Task AddEmployee(string userId)
{
var repo = parent.PayTester.GetService<StoreRepository>();
await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Employee);
}
public async Task<uint256> PayOnChain(string invoiceId)
@ -642,5 +661,33 @@ retry:
LNAddress = lnAddrUser;
return lnAddrUser;
}
public async Task ImportOldInvoices(string storeId = null)
{
storeId ??= StoreId;
var oldInvoices = File.ReadAllLines(TestUtils.GetTestDataFullPath("OldInvoices.csv"));
var oldPayments = File.ReadAllLines(TestUtils.GetTestDataFullPath("OldPayments.csv"));
var dbContext = this.parent.PayTester.GetService<ApplicationDbContextFactory>().CreateContext();
var db = (NpgsqlConnection)dbContext.Database.GetDbConnection();
await db.OpenAsync();
using (var writer = db.BeginTextImport("COPY \"Invoices\" (\"Id\",\"Blob\",\"Created\",\"CustomerEmail\",\"ExceptionStatus\",\"ItemCode\",\"OrderId\",\"Status\",\"StoreDataId\",\"Archived\",\"Blob2\") FROM STDIN DELIMITER ',' CSV HEADER"))
{
foreach (var invoice in oldInvoices)
{
var localInvoice = invoice.Replace("3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd", storeId);
await writer.WriteLineAsync(localInvoice);
}
await writer.FlushAsync();
}
using (var writer = db.BeginTextImport("COPY \"Payments\" (\"Id\",\"Blob\",\"InvoiceDataId\",\"Accounted\",\"Blob2\",\"Type\") FROM STDIN DELIMITER ',' CSV HEADER"))
{
foreach (var invoice in oldPayments)
{
var localPayment = invoice.Replace("3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd", storeId);
await writer.WriteLineAsync(localPayment);
}
await writer.FlushAsync();
}
}
}
}

@ -0,0 +1,11 @@
Id,Blob,Created,CustomerEmail,ExceptionStatus,ItemCode,OrderId,Status,StoreDataId,Archived,Blob2
Q7RqoHLngK9svM4MgRyi9y,\x1f8b0800000000000003c454cb76a24010dde72b7258cf180d08929d0a89899ae323ea4962163c0ae808ddd8344426c72f9bc57cd2fcc2340d31ea38b398cd2cfb5657d5add7fdf9fdc7fbd9f9b9845ce9ea5c1a6b9335e90db0dfd7936ca80cfd498ef45cfa52fc4818a1702bbec9893feb76d9ace3abd3d6e06e456dd76b2fece46eb8eee4d7032f9bae5f6fd44dbfb330ddd2995017a870c6691896f16200774442e4e41cae0b8c5a0cf8436dea6a4d56344d5135596e2ac286704690030f282abe349a724bd6e5a67c298cb089117746041fd815a5b2bb109304b1b6eb524812514347ef1bb2a6cd0686663e7a8b4779dcf3bd45eb5ae9bcb181e10f50c93ca6c44d1d768b3d422391817b172d2b2831880c489c229e0d4085478577890b7be51691823c418e1572d4b3c2043e60caabe29856ab578893520a58b4459a4d0d89a35bc1c54e73dec5534c84e5de8a8e520ad88c2c149ec0bb24c58ce6272c4f283e814e59399ddfe220762a48d5ebcb3f9b1a274ca380e08f24bbbaf9ec0c8b59fbdbc3d70965a20953566c8d2fbab589535b35dab61f78e9a4bb50c76b2af7bda9f18a56caca9ca1acbdf202e7a6375ccd4d50eca775539e8fbf69fa4a514c326e0e8d7870d7efb747fd66369faba38d366b4c641dc6c8c67660240b3d35c78dbe1be85dc38efcc9d7e7f832095ea4d38c1088457b5f4a9d87ee52ba5afe277a4b69fb71c1164b05270c6f5275370ec425e7cab6eb706ce511605660cf2fe575829762d7b243d85fe10a1e1e2e19475d44c161b3c9a0c818301627571717919530a0981f0767b342d8af39242ab9b0cd3588d3ad9762e0f150f784218f1f4d41b160c2685a26c57b8632c5a7b000cd80ce68789817610cace642446a36737875e5bf1aa17e99dfa179cc48b568d55df1c9ed1e7fd7a7f296cb9e0d8105a410bbf7edcee4014c4aefc60e3baa5860ffa454c279bbbb978860c4d59a77d7dce9e24e135bf54a130354487aa14855323898bf95f18916c3aeac3d2b090e7fc0860176c13d1ed2e76a40566dd0f1563d9010a88585f0356af5b3ed2f000000ffff030035140a5d88060000,2018-10-01 11:32:12+00,,,,,expired,3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd,f,
Ka6GHBrFJPRwFRga1RD6Yz,\x1f8b0800000000000003c454cd769a4014dee7297258b74682a066a7427e24e6284a729a9fc5001798083364188826c727eba28fd457e830101353db45375dcef7cdbdf7bbbf3fbfff783d383c5470a09c1c2a3632cece87ec743c759e4f9d08a98e697c7b51be543f724e195cc86f5a1eb9a31177879131ef5d8e97cc0bc2c18d978f274fc3f5e96558ce9f1ecf8c953dbcb182da98b2009834264592d4fe3280604a13ecaf05dc9618431cc44337ba46abad6aed76db50b5635d72989414fbb0c069f545d5b59ed6eff4f4da10561916c698921d5eef367c0019cd311f0401833c973998b7a5e742ef7110a7fd65e9f62889c7b6f5726b777d3fc25c9fd5ca334683c2e71724a42c9511847555b24a1287d484dcaffc9d310072b80024cd1a724403f89073e52e5ee7d84789404394e4f00633915a558656fbb881fc823120b2388ae53a8a4037529157ac452df7e991cc154a3fc594b095229cecc147b4209cadf730b738db83ce79dda3dffc60becf4953f1e33f53ea1e6a1a53f216649bb7e8a08938fa384362a870298b30e7d5ec44b25aabacf00c73e045715838a31b63f6c4343b9c9b8f78d9595a2e2e07cb30f6cfce27cb6b0b3adeed93ae5dcf5ebafd65a763d1993e31b3cbb16d0fa6b65e5e5f1bd355d7551dad0f33ec112f36f39b7e61cd543b88fb23d34b23e7eb5d769cc70fca7e4518e4b8bdde2bc3c5e85e39b9ff4ff2ee95cddb1e235e484d049e95667b7cc86acd0db7ad7086d629105e61770ff58e4258900079097c9ce1069eec0e994003ccc0e7ae7359458c39cff293a3a314e51c1811db21d42c31895a3e4d6b2d7c750a7281dbf5e686c2d515e538145b5349ac947056d441c907a20ef17e5e8095c05c96ecc6c584006f0590d296c77d915dfdaf455954c7f7d93ae3b419b466af44e7b68fbf5fa97a99eb9a4d80c7b43a79af9b2d150238b5b5bac53e652cb17fba57d278b3dd9794122c6eb6a8aeb5bd8edbcbd8d79acb18e3eab05727a909063bfd47a5e868d5ec863d4779bcfb03561c4800c1e726bd8f0694cd047d9eaa054d8021222f9fda6a1f6c7e010000ffff0300ddecc9e08e060000,2018-10-01 11:54:10+00,,,,,expired,3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd,f,
Q3kZ3F8cUD57WUqcc8QLs2,\x1f8b0800000000000003c4544972da4014ddfb1494d6099110a377806463a662b0c085f142c3176a23758b564b86b83859163952ae90564b96812259649365bff77ffff9fdfaf1f3fda654929023dd96a4a9ba5da9774ddbd06a8da5b1b3ede6741855a42fa945c408850761a6461ba3db654667539f3787fd2db51cb7bdb4a2fe68d739dc0ddd64be7bbdafef079da5ee64ce843a4085338e7d3ffb2f047026c447f681c3b2c0a8c9803f6af546bd2c2baa2ccb7545add404877042900d8f28484d949ada545bd566a32248d887883b2382cff85a23e71d08498458db71284491a861921876af3f1ec361bc5bb4d427dadb57bcfba145f4fd66fe36795a6599879438b1cd1eb04b68202270efb465694a0c020d223bfd6f64460c28260e94e6ccdc22bc11feb95597e327c5a7ff7a8708d9a6cf51d7f423f88029af916395b29c23764c2960d12449376612478f22332b3ef09e5ecb4b306333b80829603d30917f05ef9218337ab8c2ac507805e545b26bff7711bbf649def9ca9f29e50a35f108fe0852d4cd27a999cc3cdd25be5c28114d98b3748736a25bfb30b6ea5adbda786e3ceb2eebd31d5507ee5c7b45dbea563750d2deba9e7ddf1b6d173a54add5aea62ea6df1bad6db5aa93696da485c3fe60d09e0c6ac962519fec1b8632535b304516b63c2d5ab6627daa0c1cafd5d5ac6033fbfa1c5622ef45ba9e1102b176ef6ba9f3d85d4bb7ebff94de5a3a7edcb3c9629113863729bf221bc22ce79c2b3a1c9a8700304bb1e797ec56c18db1635a3e9cae700e8fce978ca30ea2603363364c237a8c85d1edb76f417134517633659b04592e6c7f07e290e54c1a5cfed59830e4f2a349534c336134ce82e213220bf129334013a006f5cfe3228c81951d0848d96236af2eb32b139addad64d343c848be68f95df1c9158fbfab5576cb59cf46c03c924adffbb1a05c8059e6ad14d845c502fb27dd12cec7e25e028211d76ede5dbd50c942215b6aae901e4a053e55a43c189ccddf4cf844d361e76ccf8cbc730bd833c00e389743fa5c0d48f20dbadcaa47e20335b1103ea52cdf1c7f030000ffff03003e6b8efb96060000,2018-10-01 11:54:32+00,,,,,expired,3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd,f,
FSktP1Nrxu7arh7TUFAgTZ,\x1f8b0800000000000003c4554b72e33610ddcf295c5c27b23e16297a1599923c63591efd55f1781620d1143124011a0035545c3a59163952ae10008415d9a54caab2c992af5f773ff48f7ffefec7cb878b0b8760e7fac2192d52396d3df0aaf4104fbce56ad4df2e1f9d9f344348c6e193a175c47615047275b37517bdfbbb948738ee6f42713779bed98feee3dde2f9dbad5b8d6f36435c3b338e81d7ce41a922e59f2d50872e00f0946524da2b46d3601c49501f5dd76b377a6dbfedf7bc96e7f9c646e88e91089624d79456b7d3ebf8dd66a76b8c501544391346dfda7d6bc7503041641f630e4298e70c969bd093de7886d63de1a2ea7b67ddc28fb76595dcfdba1db9eeb296597086cb487ea231e3b9c9a0bc75f5b42409f90044a4e34d9090c029c370b1902825746bfc2d2b50b862d132cb2c5a247b41229429344699805798ab376afdcd66a369b1a8e41ca82993335ccd1d851e8cb6b0dcab7a9e53662c0f287f97d4c0c31c119d56c5d54d01fe0b54282f3268442c774e99012ba9e4fb33311e497106550f97e73206449e0b62bbd1fe6753eb8c699a30fa9ae45809d5dd0192e884ae5acec9ce946521f55c6d4dfdaaa20cdd413fdc2671390f36eeec9977c6f162f08da457e9704576fd344ea2db8f93743d84abf0f1b9db59cf7ef3fcf4ea6ac866ddc9a0b8bf1b8ffbd37177b75ebbd3ca5bb5e61d1f6624a46132101bbf1cce5a639cf8c120ccb7f39fbf146d917c75ce2b226046f1e5c9b959064fcef5d3ff24efc939bcae3b92a5d144e1bb63372b82a2d66c6dc70a17689f03951afbf2b5de5f884b8a5198c1e9585b78f26f63a778987088e46a7eaf89899485b8bebc3ce15dca04d154ec59597bc86a04765dcc77acb43d304962b55a5ab4d6267959cba027861fa4504b9985284a85ad09f01df015cf4ef96a852805d9c090b3462823558a9ad760bc5e7c27e2fb42323b95762d559b8f1f3f3e77f531a80b3c0199307d465f0e47530c30afbd5b47ec5d310cf69f0e9f713e1c972b6794a8ff803a69c3e3993d9e58bf6b4f6c42f4cf429f349b0cde0c0bdaa9f6ebc9b0d68f48246f195049a018f0fbfefd3d47b0b3e3f67e04972c038e687d391bcd0f87bf000000ffff030075db901fe2060000,2018-10-01 11:57:15+00,customer@example.com,,,CustomOrderId,expired,3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd,f,
HuzCsv9hghew2FD6zVqUyY,\x1f8b0800000000000003c455cb76d33010ddf3153d5e439aa75377456aa794268126a913286521dbe358b52d3992ecc6edc997b1e093f80564590d694f8073d8b0f49d3b3357f3f28f6fdf1f5f1d1d1938304e8f8c8bfcc1e68515ad22b86f9f3be6c362ed969f8dd715830bcae0bda275f8cab56de19eadccf9c9f832665e100e961ebf9caccfcaf37158ccd777efcccde86c390c6a67ca0260b5b39dcb48e9470dd4a13380e08a26d82f25a3a9308604c88f9ed96f374eda56db3ae9b7fa7d4bd9302928f6e11aa715a5d5eb9c74ac5edfea28236c322c9d3125cfeca6a9ed01649463310802069cabe758e9f2be701d7ad3b3379f9069a72be406a865ad3fdf59e36ceab46a9919a341ee8bf724a42c5519a47755bd4a9280d401ee57f126880b60840670341728c664a5fc35cb96b864913c49349a4525c73e4a241aa284c313cce41b2bfdcd66a3a9313f670c882a933174678644b74a9b9797b29e879429cb0794be48aae0618a709556c6ad9a02ec2d6c509a25d0f0696aec336d9a13c1ca03316e707600950f178732da581c0aa2bbd1febda975c0741551f294645709d95d0709b447972d67b85065998b6aae56aa7e9b2cf74c67e0ada2309fd94b73ba669d513877ee70dc8d872e2e067118f9ef2e26f162085def66ddeb2ca60f7d2bee768774da9b38d9f872341a5c8d7ac562615e6dfa6e6bd6b1608a3de2450e5f5af970da1a0591653b5eba9abdf992b579f4d538ac08831ac5c75be3ecdabe354e6fff93bc5b63fbb4ee48e44a13817b436f960f59ad59db7615ce5099021115f6e56bbdbf10e624405e02fb63ade1c9dfc64ef202ccc017ee6c5c112321327e7a7cbcc73b161122312f695e7b88cd39e87551dfa1d4f6810a1ccad5aa4457da04cb6b1964cff087147229130ff931d7350156007359b2cf972b4408884600296d78c297a5a8790dcaeac5377c566682eaa9d46b29dbbcfbf8f3b9ab8f415de00988885667f471bb338500b3dabbb5c35e144361ff74f894f376b75c292558fe07e4491beeceac3eb1dd66b3ad4f6c84ab9f4575d2743278362ca890edaf26435b2f108f9e336023800410bcecdfaf3982428fdbcb11bca6093044eacbd968bedafe040000ffff030069b828dae2060000,2018-10-01 12:09:53+00,customer@example.com,,,CustomOrderId,expired,3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd,f,
YZV1PQasUKEXdSkSV69XDL,\x1f8b0800000000000003c455cb76db3610dde72b7cb86e65911429cbabca1463c792523d2c39759c05480c4584244003202bd5475fd6453e29bf50108455d9474dcfe9a64bdeb93373312f7efff3dbf3bbb3338b60ebf2ccfaed616dcfe648acc6e127bccc966b7ff06934b17e6a1842320e1f34cd159b5510c8d5d5c65f5e4c6e331ee164781f89dbe9d3d5eefd24a9974f5faffdedf8ea3ec4ad33e31878eb1c542a52f1ab01dad025009eb19cc43bc5e86a8c2309eac3f37da7e3f56cd7f55dcf751c6d23b46624863b523414db732fdc81ef38b636c2b624ca9930facadeb78d1d43c90491438c3908a19f33b85927f2b62f2807a7866c7ce1f5c49dbc76275e9a08e7a2ba6e65969ce12a961f68c278a13328efa67a8d2409c50844dcc49b2221815386e16c295146e846fb1b56a070c5a2559e1bb44c7782c42857688272012f30576f6cf477bb9daec1e28a73a0ba4c56b85a580add6b6d51b553f53ca54c5b3ea2e24d520d8705224d5a15b7690af05f608b8a32874ecc0aeb9819b08a4abe3b11e381942750f570792a6340e4a920a61bce3f9bec13a659cae84b92432554774748a223ba6a3927b52ecb523673b5d1f5db9655e48f86d1264daa4570efcf9fb83b4e96a3af24eb65e18ad4c32c49e3eb9b69b60ea1173d3c79ee7afe477f90f57a219b7bd35139b91d8f87b3b157afd7fe6cdb5fd90b77007312d1281d89fb4115ceed314e07c1282a368b9f3f978e48bf58a71511d0a3f8fc685ddd058fd6e5e3ff24efd1dabfac3b9295d644e177cb6c560c65abd9d80e152ed1ae002a1becf397767f21a92846510ec7636de0e9bf8d9de261c22196abc5a421a65296e2f2fcfc88772e534433b16355eb21b7efc1ac8bfe4e94b68f4c9244ad5623bad12679d5caa047861fa4504b994728ce84a909f01af88ae7c77cb5429482ec6028582792b12a45cbeb30de2ebe15f35d2999994ab396aacd878f1f9fbbf618b4059e824c5973469ff7075302b068bded03f6a6181afb4f874f3bef0fcb55304ad47f409db4f07066cd89ed75bb7d736253d2fc2c9a936692c1ab6141b56a7f3319c67a8344fa9a015b0914037edbbfbfe7086a336e6f47f08ee5c0116d2f67a7fb6eff17000000ffff03003a3b8e5ae2060000,2018-10-01 12:17:01+00,customer@example.com,,,CustomOrderId,expired,3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd,f,
3qtdXmycQwhWEPs91MExB2,\x1f8b0800000000000003c455cb769b4810dde72b7c5827b21002455e4502f2b0a48c1e96e538cea2a10bd106ba5177238bf8e8cb66319f34bf90a6c18aeca324e7cc6696dcba5575bb5efcfbf73f8fafcece0c828d8b33c3da487c9395e1ec215ef953d13727fe6ed8315e570c2119874f354dac97ae2b97c3b5b3783bbe4c7880a3c12a109793cdb07c3f8eb68bcdfd0767371aae7c5c3b338e81d7ce6ea122657f35401d3a07c0539692b0548cb6c63892a03e6cc7e9b4ecae69598e655b9d8eb611ba6524842b925514d3b6de5a7dc7b11d6d845d4e943361f499bd6737760c3913440e30e620847e8ef9b0bcf70a6bf6ddc3e3e48bdfe38e7f39ef4d2837931b424641af969973868b507ea211e399cea0bcabea559224641e88b08a37414202a70cc3d942a284d0b5f66f58aec2158b1669daa0795c0a12a254a1114a053cc15cbd51eb373bad76038605e740759d0c7f393714bad7e282a254053d254d5b3ea3ec45560dfb1922555e15b7ea0af077b043599e422b649971cc745941252f4fc4b825f90954bd5c9ecae812792a48d38eceaf4de609d33466f429c9a112aabd1e92e888ae7acec956976521abc15aebfaedf22270bc41b08ea362eeae9cd9865ba368e1dd93a49bf84bb21d24511c7ef83849ae7de806b71bdbba9e7deff5936ed767337be2e5e3cbd168301dd9dbeb6b67baeb2dcdb9d587190968107b62d52ffc9939c271dff5826c3d7ff335ef88f89b715a11013d8b8f77c6f0cabd332eeefe277977c6fe69df912cb4260a0f46b35a21e4b5e6c676a8708eca0ca8acb0afdfea0586a8a01805291ccf75034ffe34768a870987502ee7e38a184b998b8bf3f323deb98c114d44c98ada43eede83de97767d4122a5ed33932452bb5589aeb4495ed432e891e13729d456a6010a13d1d404f816f892a7c77cb54294826c61c8582b90a12a45cd6b315e6fbe11f23297ac99ca662d559b0f1fbfbf77f535a80b3c0119b3ea8e3eee0fa608605e7b9b07ec453134f69f2e9f76de1f962b6394a81f81ba69fee1ce3637b6db36cde6c6c6a4fa5b5437ad4906cf86056d55fbabc968ac1f91889f3360278162c02ffbf7738e60db8cdbcb11bc62297044ebd3d96abfdaff000000ffff0300fd61e9cae3060000,2018-10-01 12:24:16+00,customer@example.com,,,CustomOrderId,expired,3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd,f,
BtJGr7kt52QkZe8RdhKz42,\x1f8b0800000000000003c455c172da4810bde72b5c3a6f301208249f168462078cd780c115e21c469a161a4b9a9167465a888b2fcb219f945fc86824b3d8c566abf692a3de7b3dfda6a7bbf5e3dbf7e777676706c1c6c5993194e34bde4fa46dcd923538731c4fbe762de38f4a2124e3f051cb3a62b3f43cb91c6e7a0be77a9cf0004783fb408ca74fc3dd87eba85c3c3d5ef6b693e1bd8feb60c631f03ad82bd449d95f0d501f9d03e05b969270a7146d8d7124417dd83dc76a5996d9711dc771db3dcd115a3212c21dc92a8969779c8edb6ff72d4dc236272a9830fa9a771b1e43ce0491038c3908a1af73152d3ee55bfea9fb784978de21ebf286925537598ef12258737f52dbcc39c345283fd288f14c6750d155f52a4b12b21188b03a6f8a84044e1986b3854409a11b1ddfa83c852b152dd2b441f3782748885285462815f002737547eddf6cb79c7ebf6d9b96dd7061c139505d2ec35fce0d85eeb5c7a0d8a9ba9e72a8991b94bd49ae613f43a44a6fa4880b4168028efbe7a6025b21cb8c63a9c70a2af9eec4216b929f405505e4a9941e91a70e699ec5fa77ca3c41ddc68cbe243994423df308497424576fcf49a9ebb29055836d7401b77911f44683601347c5dcbbefcd9e7867122d468f24e926fe929483248ac3cbab69b2f2a11bac9fecce6af6b5ef26ddaecf66f674945f8f2793c1edc42e57abdeedb6bf34e71d176624a0413c12f76ee1cfcc098e5d6f14649bf9fbcfb925e22fc6694704744f3e3f18c33befc1b878f84df61e8cfdcbdc2359684f14fe369a110b21af3d37dca1c239da654065857dfe520f324405c52848e1b8bf1b78fa9f7da784987008e5727e5d2963297371717e0e5b94e52954ba7319239a881d2bea08b9fd007a70daf52a8994b91b2649a486ac725d9993bca87dd023e21729d478a6010a13d1140578097cc9d363bdf24d29c816868cb50219aa5ad4ba16e3f50a3042becb256bdab2194cf5ce878f5f2fbe7a2dd4159e828c59b5509ff7072a0298d7d1e6017b530c8dfdaf15a883f787e9ca1825ea8fa0969b7f58b8cdb2edb64dbb59b631a97e1bd5726b92c1ab6e41a57aeaaa351af60a89f8b502b6122806fcf6fdfe6924289b7e7bdb83772c058e68bd435bed77fb9f000000ffff030034f07389ec060000,2018-10-01 12:31:12+00,customer@gmail.com,,,CustomOrderId,expired,3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd,f,
VWB3riH9WuCv9NtSedaGE6,\x1f8b0800000000000003c455c1729b4810bde72b5c9cb3b21012029f222162c792bc9664a494631f0668c404984133032bc5a52fcb219f945fc83060457629deaabdec91f75e4fbfe9e96e7e7efff1f4eeec4cc3a17671a62d574383e12b7b5538a57d231610a24bd7d4de570a2e28834f4a66f0b5e738c21baecd8535b94e981f468395cfafa79be1eee3242a179baf97e6763c5cb9611d4c5908ac0e760a7952f67703d447e700e12d4d71b0938ab6c21812203f7aa6d569753aba615b9665b74dc56152521cc01dce2a89de332cc3eeebb6ae48d8e65806634a5ef056bbe143c829c76210860c3857d7e96fd6b6037d4f1457c10adb9fbd9bfb8531b059d17326cbcbd5106a9b39a36111884f24a22c5319647455bdca92806c043ca8ce9b222e80111ac2d942a00493b58a6f548ec4a58a1469daa079bce33840a944239472788699bca3f2afb75b56bfdfeee99d5ec30505634054b934d79b6b12dd2b8f7eb193753de5503137287b955cc16e8670955e4b11e31c93042cfbc3ba025b01cdb463a9430b22d8eec421f7383f81ca0a8853291d2c4e1dd23c4be7cf947e82ba8d29794e7228857ce61112e8482edf9ee152d56521aa065bab026ef3c23747037f1d47c5dc5999b30d33c6d162f41527ddc4f5703948a238b8bc9a264b17bafefda6672c67dffa76d2edba74d69b8ef2c9f5783cb81df7cae5d2bcddf63d7d6ed830c33ef1e3115fd9853bd3c7616c3b233f5bcffffa927778fca89d768441f5e4d38336bc731eb48b87ffc9de83b67f9e7b240ae589c03f5a336201e4b5e7863b543847bb0c88a8b02f8ff52043549010f9291cf777034fffb5efa430c40c02e1cd2795321622e717e7e7b045599e42a53b17312209dfd1a28e10db8fa006a75daf92489abba1024772c82ad79539c18ada073922de4821c733f55190f0a628c04a601e4b8ff5d23721205a2164b4e58b40d6a2d6b528ab578016b05d2e68d396cd60ca773e7cbcbdf8eab55057780a22a6d5427dda1fa808605e47eb07ec553114f69f56a00ade1fa62ba304cb3f825c6eee61e136cbb6dbd6cd66d9c6b8fa6d54cbad49062fba0595f2a9abd668d82bc4e3970ad80a202184afdfef772341d9f4dbeb1ebca3293044ea1dda6abfdbff020000ffff0300e1a26ae9ec060000,2018-10-01 12:33:11+00,customer@gmail.com,,,CustomOrderId,expired,3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd,f,
At4CDM5vfewV2WDHEPKRbt,\x1f8b0800000000000003c4554d73a33810bdcfaf4871de758c0db6c9696d20c9c471c61fb15d35933908688c06908824183329ffb23dec4fdabfb092205e27e59daddacb1e79efb5faa9d5ddfcf9fb1f2f1f2e2e0c1c195717c65858ae37b3ab18be6f7a5befd69f4f9781307e510a2e28838f5ad6e7bbb5eb8af56437588deeef521644f1781bf0bbd9f3a4bebe8fabd5f3b79bc17e3ad9fa51134c5904ac09764b7952fea9059aa30b80684e331cd652d1d5184302e4873d18f53b3db33f1c398edd1d3a9ac3a4a23884479c2b8969f74756d71c0d4c4dc2bec0321853f286ef0d5b3e8282722cc651c480737d9db1fde361b901320bf8c4ab83bb38c2dc22d73dd7de06f5eac6228dcd82d1a80cc547125396eb0c325a554f5912907bc04375de0c71018cd0082e5602a598ec747cab72252e55a4ccb2162d929ae31065128d51c6e11566f28ecabf351a76468ee90c7bc3960a4bc680e86a19fe7a6948f4a02d06652dcb7acea0661e50fe2eb786fd1c6195ddc810e31c931446ce6f3b0576429a1ba752979644b0facc219f717106950510e752ba589c3ba47d95de3f53e6196a9e50f29ae4580af9ca1e12e8442e9f9ee14ad76525547fed7401f745190cbc71b04be272e96e078b67d69fc62bef1b4eadd45fe36a9cc64978733b4b373e58c1e767bbbf59fc183aa965f97461cfbce2fe6e3a1dcfa776b5d90ce6fbe1da5cf61d58e0800489c7b74ee92fcc699438ae17e4bbe5af5f8a1e4fbe1ae71d61d02df9f2644c1edd27e3eae97fb2f7641c5ec71e89527b22f0dd68272c84a2f1dc72c70a17a8ce8108857df9dacc31c425895090c1697bb7f0ec5ffb4e0a23cc2014ebe5bd52264214fceaf212f6282f3250ba4b912092f29a964d84d85f839e9b6eb3496269ee810a1ccb1953ae9539c1cac60739217e92424e6716a030e56d518055c0d62c3bd54bdf8480e84490d34e2042598b46d7a1acd90046c8ea42d0b62ddbc194ef7cfcf8f9de6bb64253e1198884aa7dfa72385231c0b289368fd8bb6268ec3f6d401d7c384e574e09963f04b9dbfce3be3deeda41bfddb509567f0db5dbda64f0a65b50259f5ab546cbde229ebc55c05e0089207aff7e7f3712546dbfbdefc1479a0143a459a19dee87c35f000000ffff0300011a2ad8eb060000,2018-10-01 13:51:01+00,customer@gmail.com,,,CustomOrderId,expired,3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd,f,
1 Id Blob Created CustomerEmail ExceptionStatus ItemCode OrderId Status StoreDataId Archived Blob2
2 Q7RqoHLngK9svM4MgRyi9y \x1f8b0800000000000003c454cb76a24010dde72b7258cf180d08929d0a89899ae323ea4962163c0ae808ddd8344426c72f9bc57cd2fcc2340d31ea38b398cd2cfb5657d5add7fdf9fdc7fbd9f9b9845ce9ea5c1a6b9335e90db0dfd7936ca80cfd498ef45cfa52fc4818a1702bbec9893feb76d9ace3abd3d6e06e456dd76b2fece46eb8eee4d7032f9bae5f6fd44dbfb330ddd2995017a870c6691896f16200774442e4e41cae0b8c5a0cf8436dea6a4d56344d5135596e2ac286704690030f282abe349a724bd6e5a67c298cb089117746041fd815a5b2bb109304b1b6eb524812514347ef1bb2a6cd0686663e7a8b4779dcf3bd45eb5ae9bcb181e10f50c93ca6c44d1d768b3d422391817b172d2b2831880c489c229e0d4085478577890b7be51691823c418e1572d4b3c2043e60caabe29856ab578893520a58b4459a4d0d89a35bc1c54e73dec5534c84e5de8a8e520ad88c2c149ec0bb24c58ce6272c4f283e814e59399ddfe220762a48d5ebcb3f9b1a274ca380e08f24bbbaf9ec0c8b59fbdbc3d70965a20953566c8d2fbab589535b35dab61f78e9a4bb50c76b2af7bda9f18a56caca9ca1acbdf202e7a6375ccd4d50eca775539e8fbf69fa4a514c326e0e8d7870d7efb747fd66369faba38d366b4c641dc6c8c67660240b3d35c78dbe1be85dc38efcc9d7e7f832095ea4d38c1088457b5f4a9d87ee52ba5afe277a4b69fb71c1164b05270c6f5275370ec425e7cab6eb706ce511605660cf2fe575829762d7b243d85fe10a1e1e2e19475d44c161b3c9a0c818301627571717919530a0981f0767b342d8af39242ab9b0cd3588d3ad9762e0f150f784218f1f4d41b160c2685a26c57b8632c5a7b000cd80ce68789817610cace642446a36737875e5bf1aa17e99dfa179cc48b568d55df1c9ed1e7fd7a7f296cb9e0d8105a410bbf7edcee4014c4aefc60e3baa5860ffa454c279bbbb978860c4d59a77d7dce9e24e135bf54a130354487aa14855323898bf95f18916c3aeac3d2b090e7fc0860176c13d1ed2e76a40566dd0f1563d9010a88585f0356af5b3ed2f000000ffff030035140a5d88060000 2018-10-01 11:32:12+00 expired 3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd f
3 Ka6GHBrFJPRwFRga1RD6Yz \x1f8b0800000000000003c454cd769a4014dee7297258b74682a066a7427e24e6284a729a9fc5001798083364188826c727eba28fd457e830101353db45375dcef7cdbdf7bbbf3fbfff783d383c5470a09c1c2a3632cece87ec743c759e4f9d08a98e697c7b51be543f724e195cc86f5a1eb9a31177879131ef5d8e97cc0bc2c18d978f274fc3f5e96558ce9f1ecf8c953dbcb182da98b2009834264592d4fe3280604a13ecaf05dc9618431cc44337ba46abad6aed76db50b5635d72989414fbb0c069f545d5b59ed6eff4f4da10561916c698921d5eef367c0019cd311f0401833c973998b7a5e742ef7110a7fd65e9f62889c7b6f5726b777d3fc25c9fd5ca334683c2e71724a42c9511847555b24a1287d484dcaffc9d310072b80024cd1a724403f89073e52e5ee7d84789404394e4f00633915a558656fbb881fc823120b2388ae53a8a4037529157ac452df7e991cc154a3fc594b095229cecc147b4209cadf730b738db83ce79dda3dffc60becf4953f1e33f53ea1e6a1a53f216649bb7e8a08938fa384362a870298b30e7d5ec44b25aabacf00c73e045715838a31b63f6c4343b9c9b8f78d9595a2e2e07cb30f6cfce27cb6b0b3adeed93ae5dcf5ebafd65a763d1993e31b3cbb16d0fa6b65e5e5f1bd355d7551dad0f33ec112f36f39b7e61cd543b88fb23d34b23e7eb5d769cc70fca7e4518e4b8bdde2bc3c5e85e39b9ff4ff2ee95cddb1e235e484d049e95667b7cc86acd0db7ad7086d629105e61770ff58e4258900079097c9ce1069eec0e994003ccc0e7ae7359458c39cff293a3a314e51c1811db21d42c31895a3e4d6b2d7c750a7281dbf5e686c2d515e538145b5349ac947056d441c907a20ef17e5e8095c05c96ecc6c584006f0590d296c77d915dfdaf455954c7f7d93ae3b419b466af44e7b68fbf5fa97a99eb9a4d80c7b43a79af9b2d150238b5b5bac53e652cb17fba57d278b3dd9794122c6eb6a8aeb5bd8edbcbd8d79acb18e3eab05727a909063bfd47a5e868d5ec863d4779bcfb03561c4800c1e726bd8f0694cd047d9eaa054d8021222f9fda6a1f6c7e010000ffff0300ddecc9e08e060000 2018-10-01 11:54:10+00 expired 3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd f
4 Q3kZ3F8cUD57WUqcc8QLs2 \x1f8b0800000000000003c4544972da4014ddfb1494d6099110a377806463a662b0c085f142c3176a23758b564b86b83859163952ae90564b96812259649365bff77ffff9fdfaf1f3fda654929023dd96a4a9ba5da9774ddbd06a8da5b1b3ede6741855a42fa945c408850761a6461ba3db654667539f3787fd2db51cb7bdb4a2fe68d739dc0ddd64be7bbdafef079da5ee64ce843a4085338e7d3ffb2f047026c447f681c3b2c0a8c9803f6af546bd2c2baa2ccb7545add404877042900d8f28484d949ada545bd566a32248d887883b2382cff85a23e71d08498458db71284491a861921876af3f1ec361bc5bb4d427dadb57bcfba145f4fd66fe36795a6599879438b1cd1eb04b68202270efb465694a0c020d223bfd6f64460c28260e94e6ccdc22bc11feb95597e327c5a7ff7a8708d9a6cf51d7f423f88029af916395b29c23764c2960d12449376612478f22332b3ef09e5ecb4b306333b80829603d30917f05ef9218337ab8c2ac507805e545b26bff7711bbf649def9ca9f29e50a35f108fe0852d4cd27a999cc3cdd25be5c28114d98b3748736a25bfb30b6ea5adbda786e3ceb2eebd31d5507ee5c7b45dbea563750d2deba9e7ddf1b6d173a54add5aea62ea6df1bad6db5aa93696da485c3fe60d09e0c6ac962519fec1b8632535b304516b63c2d5ab6627daa0c1cafd5d5ac6033fbfa1c5622ef45ba9e1102b176ef6ba9f3d85d4bb7ebff94de5a3a7edcb3c9629113863729bf221bc22ce79c2b3a1c9a8700304bb1e797ec56c18db1635a3e9cae700e8fce978ca30ea2603363364c237a8c85d1edb76f417134517633659b04592e6c7f07e290e54c1a5cfed59830e4f2a349534c336134ce82e213220bf129334013a006f5cfe3228c81951d0848d96236af2eb32b139addad64d343c848be68f95df1c9158fbfab5576cb59cf46c03c924adffbb1a05c8059e6ad14d845c502fb27dd12cec7e25e028211d76ede5dbd50c942215b6aae901e4a053e55a43c189ccddf4cf844d361e76ccf8cbc730bd833c00e389743fa5c0d48f20dbadcaa47e20335b1103ea52cdf1c7f030000ffff03003e6b8efb96060000 2018-10-01 11:54:32+00 expired 3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd f
5 FSktP1Nrxu7arh7TUFAgTZ \x1f8b0800000000000003c4554b72e33610ddcf295c5c27b23e16297a1599923c63591efd55f1781620d1143124011a0035545c3a59163952ae10008415d9a54caab2c992af5f773ff48f7ffefec7cb878b0b8760e7fac2192d52396d3df0aaf4104fbce56ad4df2e1f9d9f344348c6e193a175c47615047275b37517bdfbbb948738ee6f42713779bed98feee3dde2f9dbad5b8d6f36435c3b338e81d7ce41a922e59f2d50872e00f0946524da2b46d3601c49501f5dd76b377a6dbfedf7bc96e7f9c646e88e91089624d79456b7d3ebf8dd66a76b8c501544391346dfda7d6bc7503041641f630e4298e70c969bd093de7886d63de1a2ea7b67ddc28fb76595dcfdba1db9eeb296597086cb487ea231e3b9c9a0bc75f5b42409f90044a4e34d9090c029c370b1902825746bfc2d2b50b862d132cb2c5a247b41229429344699805798ab376afdcd66a369b1a8e41ca82993335ccd1d851e8cb6b0dcab7a9e53662c0f287f97d4c0c31c119d56c5d54d01fe0b54282f3268442c774e99012ba9e4fb33311e497106550f97e73206449e0b62bbd1fe6753eb8c699a30fa9ae45809d5dd0192e884ae5acec9ce946521f55c6d4dfdaaa20cdd413fdc2671390f36eeec9977c6f162f08da457e9704576fd344ea2db8f93743d84abf0f1b9db59cf7ef3fcf4ea6ac866ddc9a0b8bf1b8ffbd37177b75ebbd3ca5bb5e61d1f6624a46132101bbf1cce5a639cf8c120ccb7f39fbf146d917c75ce2b226046f1e5c9b959064fcef5d3ff24efc939bcae3b92a5d144e1bb63372b82a2d66c6dc70a17689f03951afbf2b5de5f884b8a5198c1e9585b78f26f63a778987088e46a7eaf89899485b8bebc3ce15dca04d154ec59597bc86a04765dcc77acb43d304962b55a5ab4d6267959cba027861fa4504b9985284a85ad09f01df015cf4ef96a852805d9c090b3462823558a9ad760bc5e7c27e2fb42323b95762d559b8f1f3f3e77f531a80b3c0199307d465f0e47530c30afbd5b47ec5d310cf69f0e9f713e1c972b6794a8ff803a69c3e3993d9e58bf6b4f6c42f4cf429f349b0cde0c0bdaa9f6ebc9b0d68f48246f195049a018f0fbfefd3d47b0b3e3f67e04972c038e687d391bcd0f87bf000000ffff030075db901fe2060000 2018-10-01 11:57:15+00 customer@example.com CustomOrderId expired 3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd f
6 HuzCsv9hghew2FD6zVqUyY \x1f8b0800000000000003c455cb76d33010ddf3153d5e439aa75377456aa794268126a913286521dbe358b52d3992ecc6edc997b1e093f80564590d694f8073d8b0f49d3b3357f3f28f6fdf1f5f1d1d1938304e8f8c8bfcc1e68515ad22b86f9f3be6c362ed969f8dd715830bcae0bda275f8cab56de19eadccf9c9f832665e100e961ebf9caccfcaf37158ccd777efcccde86c390c6a67ca0260b5b39dcb48e9470dd4a13380e08a26d82f25a3a9308604c88f9ed96f374eda56db3ae9b7fa7d4bd9302928f6e11aa715a5d5eb9c74ac5edfea28236c322c9d3125cfeca6a9ed01649463310802069cabe758e9f2be701d7ad3b3379f9069a72be406a865ad3fdf59e36ceab46a9919a341ee8bf724a42c5519a47755bd4a9280d401ee57f126880b60840670341728c664a5fc35cb96b864913c49349a4525c73e4a241aa284c313cce41b2bfdcd66a3a9313f670c882a933174678644b74a9b9797b29e879429cb0794be48aae0618a709556c6ad9a02ec2d6c509a25d0f0696aec336d9a13c1ca03316e707600950f178732da581c0aa2bbd1febda975c0741551f294645709d95d0709b447972d67b85065998b6aae56aa7e9b2cf74c67e0ada2309fd94b73ba669d513877ee70dc8d872e2e067118f9ef2e26f162085def66ddeb2ca60f7d2bee768774da9b38d9f872341a5c8d7ac562615e6dfa6e6bd6b1608a3de2450e5f5af970da1a0591653b5eba9abdf992b579f4d538ac08831ac5c75be3ecdabe354e6fff93bc5b63fbb4ee48e44a13817b436f960f59ad59db7615ce5099021115f6e56bbdbf10e624405e02fb63ade1c9dfc64ef202ccc017ee6c5c112321327e7a7cbcc73b161122312f695e7b88cd39e87551dfa1d4f6810a1ccad5aa4457da04cb6b1964cff087147229130ff931d7350156007359b2cf972b4408884600296d78c297a5a8790dcaeac5377c566682eaa9d46b29dbbcfbf8f3b9ab8f415de00988885667f471bb338500b3dabbb5c35e144361ff74f894f376b75c292558fe07e4491beeceac3eb1dd66b3ad4f6c84ab9f4575d2743278362ca890edaf26435b2f108f9e336023800410bcecdfaf3982428fdbcb11bca6093044eacbd968bedafe040000ffff030069b828dae2060000 2018-10-01 12:09:53+00 customer@example.com CustomOrderId expired 3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd f
7 YZV1PQasUKEXdSkSV69XDL \x1f8b0800000000000003c455cb76db3610dde72b7cb86e65911429cbabca1463c792523d2c39759c05480c4584244003202bd5475fd6453e29bf50108455d9474dcfe9a64bdeb93373312f7efff3dbf3bbb3338b60ebf2ccfaed616dcfe648acc6e127bccc966b7ff06934b17e6a1842320e1f34cd159b5510c8d5d5c65f5e4c6e331ee164781f89dbe9d3d5eefd24a9974f5faffdedf8ea3ec4ad33e31878eb1c542a52f1ab01dad025009eb19cc43bc5e86a8c2309eac3f37da7e3f56cd7f55dcf751c6d23b46624863b523414db732fdc81ef38b636c2b624ca9930facadeb78d1d43c90491438c3908a19f33b85927f2b62f2807a7866c7ce1f5c49dbc76275e9a08e7a2ba6e65969ce12a961f68c278a13328efa67a8d2409c50844dcc49b2221815386e16c295146e846fb1b56a070c5a2559e1bb44c7782c42857688272012f30576f6cf477bb9daec1e28a73a0ba4c56b85a580add6b6d51b553f53ca54c5b3ea2e24d520d8705224d5a15b7690af05f608b8a32874ecc0aeb9819b08a4abe3b11e381942750f570792a6340e4a920a61bce3f9bec13a659cae84b92432554774748a223ba6a3927b52ecb523673b5d1f5db9655e48f86d1264daa4570efcf9fb83b4e96a3af24eb65e18ad4c32c49e3eb9b69b60ea1173d3c79ee7afe477f90f57a219b7bd35139b91d8f87b3b157afd7fe6cdb5fd90b77007312d1281d89fb4115ceed314e07c1282a368b9f3f978e48bf58a71511d0a3f8fc685ddd058fd6e5e3ff24efd1dabfac3b9295d644e177cb6c560c65abd9d80e152ed1ae002a1becf397767f21a92846510ec7636de0e9bf8d9de261c22196abc5a421a65296e2f2fcfc88772e534433b16355eb21b7efc1ac8bfe4e94b68f4c9244ad5623bad12679d5caa047861fa4504b994728ce84a909f01af88ae7c77cb5429482ec6028582792b12a45cbeb30de2ebe15f35d2999994ab396aacd878f1f9fbbf618b4059e824c5973469ff7075302b068bded03f6a6181afb4f874f3bef0fcb55304ad47f409db4f07066cd89ed75bb7d736253d2fc2c9a936692c1ab6141b56a7f3319c67a8344fa9a015b0914037edbbfbfe7086a336e6f47f08ee5c0116d2f67a7fb6eff17000000ffff03003a3b8e5ae2060000 2018-10-01 12:17:01+00 customer@example.com CustomOrderId expired 3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd f
8 3qtdXmycQwhWEPs91MExB2 \x1f8b0800000000000003c455cb769b4810dde72b7c5827b21002455e4502f2b0a48c1e96e538cea2a10bd106ba5177238bf8e8cb66319f34bf90a6c18aeca324e7cc6696dcba5575bb5efcfbf73f8fafcece0c828d8b33c3da487c9395e1ec215ef953d13727fe6ed8315e570c2119874f354dac97ae2b97c3b5b3783bbe4c7880a3c12a109793cdb07c3f8eb68bcdfd0767371aae7c5c3b338e81d7ce6ea122657f35401d3a07c0539692b0548cb6c63892a03e6cc7e9b4ecae69598e655b9d8eb611ba6524842b925514d3b6de5a7dc7b11d6d845d4e943361f499bd6737760c3913440e30e620847e8ef9b0bcf70a6bf6ddc3e3e48bdfe38e7f39ef4d2837931b424641af969973868b507ea211e399cea0bcabea559224641e88b08a37414202a70cc3d942a284d0b5f66f58aec2158b1669daa0795c0a12a254a1114a053cc15cbd51eb373bad76038605e740759d0c7f393714bad7e282a254053d254d5b3ea3ec45560dfb1922555e15b7ea0af077b043599e422b649971cc745941252f4fc4b825f90954bd5c9ecae812792a48d38eceaf4de609d33466f429c9a112aabd1e92e888ae7acec956976521abc15aebfaedf22270bc41b08ea362eeae9cd9865ba368e1dd93a49bf84bb21d24511c7ef83849ae7de806b71bdbba9e7deff5936ed767337be2e5e3cbd168301dd9dbeb6b67baeb2dcdb9d587190968107b62d52ffc9939c271dff5826c3d7ff335ef88f89b715a11013d8b8f77c6f0cabd332eeefe277977c6fe69df912cb4260a0f46b35a21e4b5e6c676a8708eca0ca8acb0afdfea0586a8a01805291ccf75034ffe34768a870987502ee7e38a184b998b8bf3f323deb98c114d44c98ada43eede83de97767d4122a5ed33932452bb5589aeb4495ed432e891e13729d456a6010a13d1d404f816f892a7c77cb54294826c61c8582b90a12a45cd6b315e6fbe11f23297ac99ca662d559b0f1fbfbf77f535a80b3c0119b3ea8e3eee0fa608605e7b9b07ec453134f69f2e9f76de1f962b6394a81f81ba69fee1ce3637b6db36cde6c6c6a4fa5b5437ad4906cf86056d55fbabc968ac1f91889f3360278162c02ffbf7738e60db8cdbcb11bc62297044ebd3d96abfdaff000000ffff0300fd61e9cae3060000 2018-10-01 12:24:16+00 customer@example.com CustomOrderId expired 3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd f
9 BtJGr7kt52QkZe8RdhKz42 \x1f8b0800000000000003c455c172da4810bde72b5c3a6f301208249f168462078cd780c115e21c469a161a4b9a9167465a888b2fcb219f945fc86824b3d8c566abf692a3de7b3dfda6a7bbf5e3dbf7e777676706c1c6c5993194e34bde4fa46dcd923538731c4fbe762de38f4a2124e3f051cb3a62b3f43cb91c6e7a0be77a9cf0004783fb408ca74fc3dd87eba85c3c3d5ef6b693e1bd8feb60c631f03ad82bd449d95f0d501f9d03e05b969270a7146d8d7124417dd83dc76a5996d9711dc771db3dcd115a3212c21dc92a8969779c8edb6ff72d4dc236272a9830fa9a771b1e43ce0491038c3908a1af73152d3ee55bfea9fb784978de21ebf286925537598ef12258737f52dbcc39c345283fd288f14c6750d155f52a4b12b21188b03a6f8a84044e1986b3854409a11b1ddfa83c852b152dd2b441f3782748885285462815f002737547eddf6cb79c7ebf6d9b96dd7061c139505d2ec35fce0d85eeb5c7a0d8a9ba9e72a8991b94bd49ae613f43a44a6fa4880b4168028efbe7a6025b21cb8c63a9c70a2af9eec4216b929f405505e4a9941e91a70e699ec5fa77ca3c41ddc68cbe243994423df308497424576fcf49a9ebb29055836d7401b77911f44683601347c5dcbbefcd9e7867122d468f24e926fe929483248ac3cbab69b2f2a11bac9fecce6af6b5ef26ddaecf66f674945f8f2793c1edc42e57abdeedb6bf34e71d176624a0413c12f76ee1cfcc098e5d6f14649bf9fbcfb925e22fc6694704744f3e3f18c33befc1b878f84df61e8cfdcbdc2359684f14fe369a110b21af3d37dca1c239da654065857dfe520f324405c52848e1b8bf1b78fa9f7da784987008e5727e5d2963297371717e0e5b94e52954ba7319239a881d2bea08b9fd007a70daf52a8994b91b2649a486ac725d9993bca87dd023e21729d478a6010a13d1140578097cc9d363bdf24d29c816868cb50219aa5ad4ba16e3f50a3042becb256bdab2194cf5ce878f5f2fbe7a2dd4159e828c59b5509ff7072a0298d7d1e6017b530c8dfdaf15a883f787e9ca1825ea8fa0969b7f58b8cdb2edb64dbb59b631a97e1bd5726b92c1ab6e41a57aeaaa351af60a89f8b502b6122806fcf6fdfe6924289b7e7bdb83772c058e68bd435bed77fb9f000000ffff030034f07389ec060000 2018-10-01 12:31:12+00 customer@gmail.com CustomOrderId expired 3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd f
10 VWB3riH9WuCv9NtSedaGE6 \x1f8b0800000000000003c455c1729b4810bde72b5c9cb3b21012029f222162c792bc9664a494631f0668c404984133032bc5a52fcb219f945fc83060457629deaabdec91f75e4fbfe9e96e7e7efff1f4eeec4cc3a17671a62d574383e12b7b5538a57d231610a24bd7d4de570a2e28834f4a66f0b5e738c21baecd8535b94e981f468395cfafa79be1eee3242a179baf97e6763c5cb9611d4c5908ac0e760a7952f67703d447e700e12d4d71b0938ab6c21812203f7aa6d569753aba615b9665b74dc56152521cc01dce2a89de332cc3eeebb6ae48d8e65806634a5ef056bbe143c829c76210860c3857d7e96fd6b6037d4f1457c10adb9fbd9bfb8531b059d17326cbcbd5106a9b39a36111884f24a22c5319647455bdca92806c043ca8ce9b222e80111ac2d942a00493b58a6f548ec4a58a1469daa079bce33840a944239472788699bca3f2afb75b56bfdfeee99d5ec30505634054b934d79b6b12dd2b8f7eb193753de5503137287b955cc16e8670955e4b11e31c93042cfbc3ba025b01cdb463a9430b22d8eec421f7383f81ca0a8853291d2c4e1dd23c4be7cf947e82ba8d29794e7228857ce61112e8482edf9ee152d56521aa065bab026ef3c23747037f1d47c5dc5999b30d33c6d162f41527ddc4f5703948a238b8bc9a264b17bafefda6672c67dffa76d2edba74d69b8ef2c9f5783cb81df7cae5d2bcddf63d7d6ed830c33ef1e3115fd9853bd3c7616c3b233f5bcffffa927778fca89d768441f5e4d38336bc731eb48b87ffc9de83b67f9e7b240ae589c03f5a336201e4b5e7863b543847bb0c88a8b02f8ff52043549010f9291cf777034fffb5efa430c40c02e1cd2795321622e717e7e7b045599e42a53b17312209dfd1a28e10db8fa006a75daf92489abba1024772c82ad79539c18ada073922de4821c733f55190f0a628c04a601e4b8ff5d23721205a2164b4e58b40d6a2d6b528ab578016b05d2e68d396cd60ca773e7cbcbdf8eab55057780a22a6d5427dda1fa808605e47eb07ec553114f69f56a00ade1fa62ba304cb3f825c6eee61e136cbb6dbd6cd66d9c6b8fa6d54cbad49062fba0595f2a9abd668d82bc4e3970ad80a202184afdfef772341d9f4dbeb1ebca3293044ea1dda6abfdbff020000ffff0300e1a26ae9ec060000 2018-10-01 12:33:11+00 customer@gmail.com CustomOrderId expired 3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd f
11 At4CDM5vfewV2WDHEPKRbt \x1f8b0800000000000003c4554d73a33810bdcfaf4871de758c0db6c9696d20c9c471c61fb15d35933908688c06908824183329ffb23dec4fdabfb092205e27e59daddacb1e79efb5faa9d5ddfcf9fb1f2f1f2e2e0c1c195717c65858ae37b3ab18be6f7a5befd69f4f9781307e510a2e28838f5ad6e7bbb5eb8af56437588deeef521644f1781bf0bbd9f3a4bebe8fabd5f3b79bc17e3ad9fa51134c5904ac09764b7952fea9059aa30b80684e331cd652d1d5184302e4873d18f53b3db33f1c398edd1d3a9ac3a4a23884479c2b8969f74756d71c0d4c4dc2bec0321853f286ef0d5b3e8282722cc651c480737d9db1fde361b901320bf8c4ab83bb38c2dc22d73dd7de06f5eac6228dcd82d1a80cc547125396eb0c325a554f5912907bc04375de0c71018cd0082e5602a598ec747cab72252e55a4ccb2162d929ae31065128d51c6e11566f28ecabf351a76468ee90c7bc3960a4bc680e86a19fe7a6948f4a02d06652dcb7acea0661e50fe2eb786fd1c6195ddc810e31c931446ce6f3b0576429a1ba752979644b0facc219f717106950510e752ba589c3ba47d95de3f53e6196a9e50f29ae4580af9ca1e12e8442e9f9ee14ad76525547fed7401f745190cbc71b04be272e96e078b67d69fc62bef1b4eadd45fe36a9cc64978733b4b373e58c1e767bbbf59fc183aa965f97461cfbce2fe6e3a1dcfa776b5d90ce6fbe1da5cf61d58e0800489c7b74ee92fcc699438ae17e4bbe5af5f8a1e4fbe1ae71d61d02df9f2644c1edd27e3eae97fb2f7641c5ec71e89527b22f0dd68272c84a2f1dc72c70a17a8ce8108857df9dacc31c425895090c1697bb7f0ec5ffb4e0a23cc2014ebe5bd52264214fceaf212f6282f3250ba4b912092f29a964d84d85f839e9b6eb3496269ee810a1ccb1953ae9539c1cac60739217e92424e6716a030e56d518055c0d62c3bd54bdf8480e84490d34e2042598b46d7a1acd90046c8ea42d0b62ddbc194ef7cfcf8f9de6bb64253e1198884aa7dfa72385231c0b289368fd8bb6268ec3f6d401d7c384e574e09963f04b9dbfce3be3deeda41bfddb509567f0db5dbda64f0a65b50259f5ab546cbde229ebc55c05e0089207aff7e7f3712546dbfbdefc1479a0143a459a19dee87c35f000000ffff0300011a2ad8eb060000 2018-10-01 13:51:01+00 customer@gmail.com CustomOrderId expired 3sgUCCtUBg6S8LJkrbdfAWbsJMqByFLfvSqjG6xKBWEd f

@ -0,0 +1,11 @@
Id,Blob,InvoiceDataId,Accounted,Blob2,Type
a8be47ca93a1583f60b25fb1279b6c162af731024d169f8d1cd0d29d1d391132-0,\x1f8b0800000000000003748f396ec33010457b9f4260ed82c39d2ead20958b2050a966c425201289824205100c9f2c458e942b447216b8c9af66fe03de603edf3fcebbaa22537021bd05dfa43e904305921b4139a36cbfd13c9731a7a1ac847006c02d78eb3df3149cf13682129e320e513304a59ced98ee40c68e29caa33408963b14ba0b68e84fc89f79be7a9d1692de04345a10d26b0116c0482339a008144da7509a2e4aca95d7511923b534fadb87cee57928c1afca32cde15aba69194baeb3df5e23c7a62637f5032e7d18ca1d16dce8b925751e629a7a2c290ff5666bc901ecbe258fc7fb75bc6a5b720a4fe896758ff8f21a2eff289b65fc3d7a4acf81ec2e5f000000ffff03002eb110c572010000,Q7RqoHLngK9svM4MgRyi9y,t,,
c703dd8ae34622dfb96d677260631c39af483918d9e63676839be0db1b8a9268-1,\x1f8b0800000000000003748f3d6ec3300c85f79cc2d09c413fb644658c8b4e198ac2a3175aa20ba1b565b8720123c8c93af448bd42e5040dbaf44de47bc0f7c8efcfaff3ae28d84c8ec207f9260cc40e85a84a59951c14df6f695cd214c39872c2345809283adf11b71d28a39526edad00654bc05e59e1b2c58dd446fbcefa5eca529302f49e2b67b8e057b13b79b971bdecace37709835694d0a1e08012786572a3b2d944aba4ef2de81e205f50213907e6c643e7e23226f21999e685aea69bd729c53afaed35766c6af6c77ec275a0313d60c22d3db7ac8e631fe601538863bdd15a76a8a4d8b7ecf9f898e72bb765277a41b7e6bdc7b777bafcc36cd6e9b7f5145e89ed2e3f000000ffff03000f0bf2f373010000,Q7RqoHLngK9svM4MgRyi9y,t,,
f237a3d75018f4435927f5b1be1a8902045d88ddda39c25254214c3e484bd383-1,\x1f8b080000000000000374904d6ac3301046f73945d03a14fdc6a32ce3925528a564e9cd581a1593da0a8e9c62424ed6458fd42b547669e8a6b39bf7c193e6fbfaf8bc2e964b76a1fedcc48e6d967c35ed3d396a2ee40f4d4b190aa3a5e5609599d38ed27bec8f3b9a32fec0a791731287748a4d973267a0bcd2b50645dac96c30d249653d7a0f603cd75c821558532d829185b15a052dc0705fa05245905ccc62ceeee661f66ac36bedf97d44815668a851704009dc14be26653344aba40f16d601c0d660909c83e2c787cec5a14be4b332f503cdd0f5e329c532fae930b63d94ec0f7ec6b1a52e3d62c229bd56ac8c5d68fa1653aeae9c6c15db88756e6a55b197ed2e6fb3b9624ff7c232cb8565b4a75774635e03be9de9f6cf4387f1f4fb957d7324b6b87d030000ffff0300eef4b353b2010000,Q7RqoHLngK9svM4MgRyi9y,t,,
aedd36b5c976e56543cd7a5e207ea13dc801764cb3682474ab75a54399f20c27-9,\x1f8b08000000000000037490bf6ec2301087779e02794695ff3b66241513aaaa8a91e5629fab8812a3e050458827ebd047ea2bd44e0bead29b7cdfcffacebeaf8fcfcb6c3e2767ec4f6decc872ce16a5efd1617b46bf6d0f58a052dc68658d9cd20ed37becf76b2c197da0a5f894c4211d63dba5cc0937d4056ead14a08c82c6482e75d508e98ca6cc55c20333c8a94203ce4ba1152aa39d6d94d0de03523b8929b99b87c99b6779c128a3bfc50c582621d0c02b69a571ba623a7068b4e516adb0547b45196f022aea2bf3e303e7e2d025f45999fa0127e8faf198621d7df918596d6bf2073fc378c02e3d4282925e76a48e5d68fb03a4bcbabad87664c9163bf2b25ae75380b713e6eee9bead0cf3b632dae02bb8f176e7facf94ed78bcbd63d3ee91ccaedf000000ffff030012c428b2af010000,Q7RqoHLngK9svM4MgRyi9y,t,,
3358197dcb30420d3487516e8ec9dcc0f6d03f16b4f41b77cd3ad429b1e529ee-0,\x1f8b0800000000000003748fb14ec3301086f73e45e4b9839db3e3b86383983a209431cbe57c4116248e828314557d32061e8957202914b170d3ddff49dfe9ff7cff38efb24c4c4c1cded8d7a16771c894d18536e024ec371ae734c630a49508e6dcb16955eebc06246fad6a3bdd6a5540e7655790f444aee4828d2a2d68e9750e925aeb95332580fc19f16b9eaf5e50680dc8fcc69545a774993b7285c1429adc90266d491a8980502290b2d2b80ebd85d27efb9028ce4362bf2ad334f335a4691953aca2dfaa89635d893ff1032e3d0fe90e136ef4dc882a0e5d987a4c210ed5666bc441ed1bf178bc5fb70e5f5e79bd4efc84b4dc82cb3fca7a196f4f4fe199c5eef2050000ffff0300b173d7d272010000,Q7RqoHLngK9svM4MgRyi9y,t,,
03d66d2d633dac96cc45546a0555cb917f5d2f0d37dddfdf67e48f88ae728aa1-5,\x1f8b08000000000000037490bd6ec2301446779e027946951dff33928a095555c598e5625fa38812a3e050458827ebd047ea2bd40e2aead2ebe99e4f3ab6bfefcfafeb6c3e2717eccf6decc872ce1665efd1617b41bf6d8f58a0144652a3f93ded307dc4feb0c692d1275aa69a9238a4536cbb94390166405780c6988042691ff2f15c535f05e975b0cceda4a4528114423a671538ee15afbcf25e514ee524a6e4611e26af50a085a68f611a2c1312ad1396a3715c280f2cd8c0b50dc2535571b6432a0ce74aed8cbefbc0b93874097d56a67ec009ba7e3ca558475f3e4656db9afcc1af301eb14bcf90a0a4d786d4b10b6d7f8494abab8bad214bc9b45a34e46db5cecb246ec8cba3afcc725f196d700f6ecc6b80f733defeb9673b9e7e5fb2690f4866b71f000000ffff03007bb33a2fb1010000,Q7RqoHLngK9svM4MgRyi9y,t,,
dc130d025a4bd2e7ee83b7707d850e7c1a52872c222a5bc2e05d3357e79aa762-1,\x1f8b08000000000000037490bf6ec2301087779e027946e8fc2f761849c584aaaa6264b9d817145162141caa08f1641dfa487d85daa98abaf426dfefb3beb3efebe3f3369bcfd995fa4b1b3ab69af345ee7b72d45ec9efda13e5506bae1557d64eb4a3f81efae386328325e4121309433c87b68b296785405322196da4d49ec0095d0b1442386bb4e0681c90d5c61ba88d954464bc50b54610e0b9f40ef82406f6300f9317ea74c5c1a3b8c1922b4da553a524eba42a3cf2a66ca4291be5a11092d704ca4a5914b5353f3e742e0c5d249f940dbe5d684a5d3f9e63a882cf3f63eb5dc5fec42f389ea88b4f1831d3db9e55a16bdafe8431edaecaba3d5bc162cf5ed79b748afd40a9797e6c2bd365e65b3aa01b533b4dbeff3364379e7f9fb16d8fc466f76f000000ffff0300f93b039faf010000,Q7RqoHLngK9svM4MgRyi9y,f,,
afc39884e024cbb3a48ca997b7e878b199d85fe97f90f94471364cc866ba83ad-1,\x1f8b080000000000000374904d6ac3301085f73945d03a14c992f593655cb20aa5942cbd1949a362d258c191534cc8c9bae8917a854a2e0ddd7476ef3de6d3e87d7d7c5e17cb25b9e070ee624fd64bb62a7a4087dd05fdbe3b6231eb9ad5a21246ce698fe93d0e872d968c3ed032d59cc4319d62d7a7ec13f09a5b90d269e1b8544c88600c55014d1dbc36c632a5515b651418ed4058ee6c25906a91258740d90ca6e44e1e672eb5402b47efc3f23e13351a270cc7bc2aa407164ce0ca04e1a9ac38b34885e65c4aabd50f0f9c8b639fd067641a469c4d374ca7149be8cbc7c866df903ff6334c47ecd3232428e9b5254dec43371c21e5ea9a426bc95ae59e562d79d96cb398c12d79baf795bddc57b676f80a6eca32c0db196fffbcb39f4ebf97ecba0392c5ed1b0000ffff030018cc54a1b1010000,Q7RqoHLngK9svM4MgRyi9y,t,,
6f2b8513ebfdb14b41d4de235f050cd028400cb01fde700d72c6bedc2ad8af1e-5,\x1f8b08000000000000037490bf6ac3301087f73c45d01cc249966425635c3285528a472ffa732a26b5151c39c5843c59873e525fa192434397de22ddef83efa4fbfefcba2e964b72c1e1dc869e6c977495fb012db6177475db610e85908a6d24c04c7b8c1f6138ee313358432e369330c65368fb987242517ba79876d6a0952503578243ea0d80e5c0944b2708e159e1d0714eb931d43b34b4508219e941cc62200ff378f752c719c2a368a937942b445158855e6967b8b05c0a2e9934c0b59585960c0b55d272a3cabb4f5b1bc63ea24b4aafdfcf38a776984e3154c1e59f915d5d913ff18b9e3aece3938e3ad36b43aad0fb76e8744cbbabb2ae215b5835e475b74fb7388c989ae7c7b6325d677ec0376da7d4ce936fff0ca9a7d3ef330eed11c9e2f6030000ffff0300b09cf40daf010000,Q7RqoHLngK9svM4MgRyi9y,f,,
3a6659e189f83f649c0010305def1b8fb78efdbe8c0ce44d56c8c22fff742b55-61,\x1f8b08000000000000037490bd6ec3201485f73c45c41c5580f973c6b8ca14555595d10be65e222b8d891c9cca8af2641dfa487d8582ab585d7a27ce39f001e7fbf3ebb6582ec915fb4b1b3ab25eb255d63d3a6caf08fbf684d994525121a85653da61fc08fd718b39a34f340f9f9230c47368bb987c22256fb4f09e7bc79d914a000aea8c6bd083c1461bcfd2524241593a5f3a250aef8d2991c952a9c21630812999c9c3c4d55615cad37998b625131eb8e5c080496c941525958c97b2708c838286022ac5b56060f42fcf3a17862e222464ec079c4cd78fe718aa00f96364b3afc81ffbd58e27ece2b38d36a7b79a54a1f36d7fb2315557655a4dd64c30b1aac9db669b84b7ef174cea652e2c99a9b064edf060ddf8d873ffe7a2fd787e3c65d71e912cee3f000000ffff0300a991d8c4b2010000,Q7RqoHLngK9svM4MgRyi9y,t,,
1 Id Blob InvoiceDataId Accounted Blob2 Type
2 a8be47ca93a1583f60b25fb1279b6c162af731024d169f8d1cd0d29d1d391132-0 \x1f8b0800000000000003748f396ec33010457b9f4260ed82c39d2ead20958b2050a966c425201289824205100c9f2c458e942b447216b8c9af66fe03de603edf3fcebbaa22537021bd05dfa43e904305921b4139a36cbfd13c9731a7a1ac847006c02d78eb3df3149cf13682129e320e513304a59ced98ee40c68e29caa33408963b14ba0b68e84fc89f79be7a9d1692de04345a10d26b0116c0482339a008144da7509a2e4aca95d7511923b534fadb87cee57928c1afca32cde15aba69194baeb3df5e23c7a62637f5032e7d18ca1d16dce8b925751e629a7a2c290ff5666bc901ecbe258fc7fb75bc6a5b720a4fe896758ff8f21a2eff289b65fc3d7a4acf81ec2e5f000000ffff03002eb110c572010000 Q7RqoHLngK9svM4MgRyi9y t
3 c703dd8ae34622dfb96d677260631c39af483918d9e63676839be0db1b8a9268-1 \x1f8b0800000000000003748f3d6ec3300c85f79cc2d09c413fb644658c8b4e198ac2a3175aa20ba1b565b8720123c8c93af448bd42e5040dbaf44de47bc0f7c8efcfaff3ae28d84c8ec207f9260cc40e85a84a59951c14df6f695cd214c39872c2345809283adf11b71d28a39526edad00654bc05e59e1b2c58dd446fbcefa5eca529302f49e2b67b8e057b13b79b971bdecace37709835694d0a1e08012786572a3b2d944aba4ef2de81e205f50213907e6c643e7e23226f21999e685aea69bd729c53afaed35766c6af6c77ec275a0313d60c22d3db7ac8e631fe601538863bdd15a76a8a4d8b7ecf9f898e72bb765277a41b7e6bdc7b777bafcc36cd6e9b7f5145e89ed2e3f000000ffff03000f0bf2f373010000 Q7RqoHLngK9svM4MgRyi9y t
4 f237a3d75018f4435927f5b1be1a8902045d88ddda39c25254214c3e484bd383-1 \x1f8b080000000000000374904d6ac3301046f73945d03a14fdc6a32ce3925528a564e9cd581a1593da0a8e9c62424ed6458fd42b547669e8a6b39bf7c193e6fbfaf8bc2e964b76a1fedcc48e6d967c35ed3d396a2ee40f4d4b190aa3a5e5609599d38ed27bec8f3b9a32fec0a791731287748a4d973267a0bcd2b50645dac96c30d249653d7a0f603cd75c821558532d829185b15a052dc0705fa05245905ccc62ceeee661f66ac36bedf97d44815668a851704009dc14be26653344aba40f16d601c0d660909c83e2c787cec5a14be4b332f503cdd0f5e329c532fae930b63d94ec0f7ec6b1a52e3d62c229bd56ac8c5d68fa1653aeae9c6c15db88756e6a55b197ed2e6fb3b9624ff7c232cb8565b4a75774635e03be9de9f6cf4387f1f4fb957d7324b6b87d030000ffff0300eef4b353b2010000 Q7RqoHLngK9svM4MgRyi9y t
5 aedd36b5c976e56543cd7a5e207ea13dc801764cb3682474ab75a54399f20c27-9 \x1f8b08000000000000037490bf6ec2301087779e02794695ff3b66241513aaaa8a91e5629fab8812a3e050458827ebd047ea2bd44e0bead29b7cdfcffacebeaf8fcfcb6c3e2767ec4f6decc872ce16a5efd1617b46bf6d0f58a052dc68658d9cd20ed37becf76b2c197da0a5f894c4211d63dba5cc0937d4056ead14a08c82c6482e75d508e98ca6cc55c20333c8a94203ce4ba1152aa39d6d94d0de03523b8929b99b87c99b6779c128a3bfc50c582621d0c02b69a571ba623a7068b4e516adb0547b45196f022aea2bf3e303e7e2d025f45999fa0127e8faf198621d7df918596d6bf2073fc378c02e3d4282925e76a48e5d68fb03a4bcbabad87664c9163bf2b25ae75380b713e6eee9bead0cf3b632dae02bb8f176e7facf94ed78bcbd63d3ee91ccaedf000000ffff030012c428b2af010000 Q7RqoHLngK9svM4MgRyi9y t
6 3358197dcb30420d3487516e8ec9dcc0f6d03f16b4f41b77cd3ad429b1e529ee-0 \x1f8b0800000000000003748fb14ec3301086f73e45e4b9839db3e3b86383983a209431cbe57c4116248e828314557d32061e8957202914b170d3ddff49dfe9ff7cff38efb24c4c4c1cded8d7a16771c894d18536e024ec371ae734c630a49508e6dcb16955eebc06246fad6a3bdd6a5540e7655790f444aee4828d2a2d68e9750e925aeb95332580fc19f16b9eaf5e50680dc8fcc69545a774993b7285c1429adc90266d491a8980502290b2d2b80ebd85d27efb9028ce4362bf2ad334f335a4691953aca2dfaa89635d893ff1032e3d0fe90e136ef4dc882a0e5d987a4c210ed5666bc441ed1bf178bc5fb70e5f5e79bd4efc84b4dc82cb3fca7a196f4f4fe199c5eef2050000ffff0300b173d7d272010000 Q7RqoHLngK9svM4MgRyi9y t
7 03d66d2d633dac96cc45546a0555cb917f5d2f0d37dddfdf67e48f88ae728aa1-5 \x1f8b08000000000000037490bd6ec2301446779e027946951dff33928a095555c598e5625fa38812a3e050458827ebd047ea2bd40e2aead2ebe99e4f3ab6bfefcfafeb6c3e2717eccf6decc872ce1665efd1617b41bf6d8f58a0144652a3f93ded307dc4feb0c692d1275aa69a9238a4536cbb94390166405780c6988042691ff2f15c535f05e975b0cceda4a4528114423a671538ee15afbcf25e514ee524a6e4611e26af50a085a68f611a2c1312ad1396a3715c280f2cd8c0b50dc2535571b6432a0ce74aed8cbefbc0b93874097d56a67ec009ba7e3ca558475f3e4656db9afcc1af301eb14bcf90a0a4d786d4b10b6d7f8494abab8bad214bc9b45a34e46db5cecb246ec8cba3afcc725f196d700f6ecc6b80f733defeb9673b9e7e5fb2690f4866b71f000000ffff03007bb33a2fb1010000 Q7RqoHLngK9svM4MgRyi9y t
8 dc130d025a4bd2e7ee83b7707d850e7c1a52872c222a5bc2e05d3357e79aa762-1 \x1f8b08000000000000037490bf6ec2301087779e027946e8fc2f761849c584aaaa6264b9d817145162141caa08f1641dfa487d85daa98abaf426dfefb3beb3efebe3f3369bcfd995fa4b1b3ab69af345ee7b72d45ec9efda13e5506bae1557d64eb4a3f81efae386328325e4121309433c87b68b296785405322196da4d49ec0095d0b1442386bb4e0681c90d5c61ba88d954464bc50b54610e0b9f40ef82406f6300f9317ea74c5c1a3b8c1922b4da553a524eba42a3cf2a66ca4291be5a11092d704ca4a5914b5353f3e742e0c5d249f940dbe5d684a5d3f9e63a882cf3f63eb5dc5fec42f389ea88b4f1831d3db9e55a16bdafe8431edaecaba3d5bc162cf5ed79b748afd40a9797e6c2bd365e65b3aa01b533b4dbeff3364379e7f9fb16d8fc466f76f000000ffff0300f93b039faf010000 Q7RqoHLngK9svM4MgRyi9y f
9 afc39884e024cbb3a48ca997b7e878b199d85fe97f90f94471364cc866ba83ad-1 \x1f8b080000000000000374904d6ac3301085f73945d03a14c992f593655cb20aa5942cbd1949a362d258c191534cc8c9bae8917a854a2e0ddd7476ef3de6d3e87d7d7c5e17cb25b9e070ee624fd64bb62a7a4087dd05fdbe3b6231eb9ad5a21246ce698fe93d0e872d968c3ed032d59cc4319d62d7a7ec13f09a5b90d269e1b8544c88600c55014d1dbc36c632a5515b651418ed4058ee6c25906a91258740d90ca6e44e1e672eb5402b47efc3f23e13351a270cc7bc2aa407164ce0ca04e1a9ac38b34885e65c4aabd50f0f9c8b639fd067641a469c4d374ca7149be8cbc7c866df903ff6334c47ecd3232428e9b5254dec43371c21e5ea9a426bc95ae59e562d79d96cb398c12d79baf795bddc57b676f80a6eca32c0db196fffbcb39f4ebf97ecba0392c5ed1b0000ffff030018cc54a1b1010000 Q7RqoHLngK9svM4MgRyi9y t
10 6f2b8513ebfdb14b41d4de235f050cd028400cb01fde700d72c6bedc2ad8af1e-5 \x1f8b08000000000000037490bf6ac3301087f73c45d01cc249966425635c3285528a472ffa732a26b5151c39c5843c59873e525fa192434397de22ddef83efa4fbfefcba2e964b72c1e1dc869e6c977495fb012db6177475db610e85908a6d24c04c7b8c1f6138ee313358432e369330c65368fb987242517ba79876d6a0952503578243ea0d80e5c0944b2708e159e1d0714eb931d43b34b4508219e941cc62200ff378f752c719c2a368a937942b445158855e6967b8b05c0a2e9934c0b59585960c0b55d272a3cabb4f5b1bc63ea24b4aafdfcf38a776984e3154c1e59f915d5d913ff18b9e3aece3938e3ad36b43aad0fb76e8744cbbabb2ae215b5835e475b74fb7388c989ae7c7b6325d677ec0376da7d4ce936fff0ca9a7d3ef330eed11c9e2f6030000ffff0300b09cf40daf010000 Q7RqoHLngK9svM4MgRyi9y f
11 3a6659e189f83f649c0010305def1b8fb78efdbe8c0ce44d56c8c22fff742b55-61 \x1f8b08000000000000037490bd6ec3201485f73c45c41c5580f973c6b8ca14555595d10be65e222b8d891c9cca8af2641dfa487d8582ab585d7a27ce39f001e7fbf3ebb6582ec915fb4b1b3ab25eb255d63d3a6caf08fbf684d994525121a85653da61fc08fd718b39a34f340f9f9230c47368bb987c22256fb4f09e7bc79d914a000aea8c6bd083c1461bcfd2524241593a5f3a250aef8d2991c952a9c21630812999c9c3c4d55615cad37998b625131eb8e5c080496c941525958c97b2708c838286022ac5b56060f42fcf3a17862e222464ec079c4cd78fe718aa00f96364b3afc81ffbd58e27ece2b38d36a7b79a54a1f36d7fb2315557655a4dd64c30b1aac9db669b84b7ef174cea652e2c99a9b064edf060ddf8d873ffe7a2fd787e3c65d71e912cee3f000000ffff0300a991d8c4b2010000 Q7RqoHLngK9svM4MgRyi9y t

@ -272,7 +272,8 @@ namespace BTCPayServer.Tests
"https://www.btse.com", // not allowing to be hit from circleci
"https://www.bitpay.com", // not allowing to be hit from circleci
"https://support.bitpay.com",
"https://www.coingecko.com" // unhappy service
"https://www.coingecko.com", // unhappy service
"https://www.wasabiwallet.io/" // returning Forbidden
};
foreach (var match in regex.Matches(text).OfType<Match>())
@ -464,7 +465,7 @@ retry:
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();
expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/dom-confetti@{version}")).Content.ReadAsStringAsync()).Trim();
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "vue-sortable", "sortable.min.js").Trim();
@ -494,6 +495,10 @@ retry:
version = Regex.Match(actual, "Original file: /npm/decimal\\.js@([0-9]+.[0-9]+.[0-9]+)/decimal\\.js").Groups[1].Value;
expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/decimal.js@{version}/decimal.min.js")).Content.ReadAsStringAsync()).Trim();
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "bbqr", "bbqr.iife.js").Trim();
expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/bbqr@1.0.0/dist/bbqr.iife.js")).Content.ReadAsStringAsync()).Trim();
EqualJsContent(expected, actual);
}
private void EqualJsContent(string expected, string actual)

@ -73,6 +73,9 @@ using Xunit;
using Xunit.Abstractions;
using Xunit.Sdk;
using CreateInvoiceRequest = BTCPayServer.Client.Models.CreateInvoiceRequest;
using CreatePaymentRequestRequest = BTCPayServer.Client.Models.CreatePaymentRequestRequest;
using MarkPayoutRequest = BTCPayServer.Client.Models.MarkPayoutRequest;
using PaymentRequestData = BTCPayServer.Client.Models.PaymentRequestData;
using RatesViewModel = BTCPayServer.Models.StoreViewModels.RatesViewModel;
namespace BTCPayServer.Tests
@ -1477,6 +1480,7 @@ namespace BTCPayServer.Tests
[Fact]
[Trait("Lightning", "Lightning")]
[Trait("Integration", "Integration")]
public async Task CanSetPaymentMethodLimits()
{
using var tester = CreateServerTester();
@ -1512,9 +1516,10 @@ namespace BTCPayServer.Tests
ItemDesc = "Some description",
FullNotifications = true
}, Facade.Merchant);
Assert.Single(invoice.CryptoInfo);
Assert.Equal(PaymentTypes.LightningLike.ToString(), invoice.CryptoInfo[0].PaymentType);
// LN and LNURL
Assert.Equal(2, invoice.CryptoInfo.Length);
Assert.Contains(invoice.CryptoInfo, c => c.PaymentType == PaymentTypes.LNURLPay.ToString());
Assert.Contains(invoice.CryptoInfo, c => c.PaymentType == PaymentTypes.LightningLike.ToString());
// Let's replicate https://github.com/btcpayserver/btcpayserver/issues/2963
// We allow BTC for more than 5 USD, and LN for less than 150. The default is LN, so the default
@ -1943,6 +1948,173 @@ namespace BTCPayServer.Tests
entity.GetPaymentMethods().First().Calculate();
}
[Fact()]
[Trait("Integration", "Integration")]
public async Task EnsureWebhooksTrigger()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
await user.GrantAccessAsync();
user.RegisterDerivationScheme("BTC");
await user.SetupWebhook();
var client = await user.CreateClient();
var invoice = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest()
{
Amount = 0.00m,
Currency = "BTC"
});;
await user.AssertHasWebhookEvent(WebhookEventType.InvoiceCreated, (WebhookInvoiceEvent x)=> Assert.Equal(invoice.Id, x.InvoiceId));
//invoice payment webhooks
invoice = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest()
{
Amount = 0.01m,
Currency = "BTC"
});
var invoicePaymentRequest = new BitcoinUrlBuilder((await client.GetInvoicePaymentMethods(user.StoreId, invoice.Id)).Single(model =>
PaymentMethodId.Parse(model.PaymentMethod) ==
new PaymentMethodId("BTC", BitcoinPaymentType.Instance))
.PaymentLink, tester.ExplorerNode.Network);
var halfPaymentTx = await tester.ExplorerNode.SendToAddressAsync(invoicePaymentRequest.Address, Money.Coins(invoicePaymentRequest.Amount.ToDecimal(MoneyUnit.BTC)/2m));
invoicePaymentRequest = new BitcoinUrlBuilder((await client.GetInvoicePaymentMethods(user.StoreId, invoice.Id)).Single(model =>
PaymentMethodId.Parse(model.PaymentMethod) ==
new PaymentMethodId("BTC", BitcoinPaymentType.Instance))
.PaymentLink, tester.ExplorerNode.Network);
var remainingPaymentTx = await tester.ExplorerNode.SendToAddressAsync(invoicePaymentRequest.Address, Money.Coins(invoicePaymentRequest.Amount.ToDecimal(MoneyUnit.BTC)));
await user.AssertHasWebhookEvent(WebhookEventType.InvoiceCreated, (WebhookInvoiceEvent x)=> Assert.Equal(invoice.Id, x.InvoiceId));
await user.AssertHasWebhookEvent(WebhookEventType.InvoiceProcessing, (WebhookInvoiceEvent x)=> Assert.Equal(invoice.Id, x.InvoiceId));
await user.AssertHasWebhookEvent(WebhookEventType.InvoiceReceivedPayment,
(WebhookInvoiceReceivedPaymentEvent x) =>
{
Assert.Equal(invoice.Id, x.InvoiceId);
Assert.Contains(halfPaymentTx.ToString(), x.Payment.Id);
});
await user.AssertHasWebhookEvent(WebhookEventType.InvoiceReceivedPayment,
(WebhookInvoiceReceivedPaymentEvent x) =>
{
Assert.Equal(invoice.Id, x.InvoiceId);
Assert.Contains(remainingPaymentTx.ToString(), x.Payment.Id);
});
await tester.ExplorerNode.GenerateAsync(1);
await user.AssertHasWebhookEvent(WebhookEventType.InvoicePaymentSettled,
(WebhookInvoiceReceivedPaymentEvent x) =>
{
Assert.Equal(invoice.Id, x.InvoiceId);
Assert.Contains(halfPaymentTx.ToString(), x.Payment.Id);
});
await user.AssertHasWebhookEvent(WebhookEventType.InvoicePaymentSettled,
(WebhookInvoiceReceivedPaymentEvent x) =>
{
Assert.Equal(invoice.Id, x.InvoiceId);
Assert.Contains(remainingPaymentTx.ToString(), x.Payment.Id);
});
await user.AssertHasWebhookEvent(WebhookEventType.InvoiceSettled, (WebhookInvoiceEvent x)=> Assert.Equal(invoice.Id, x.InvoiceId));
invoice = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest()
{
Amount = 0.01m,
Currency = "BTC",
});
invoicePaymentRequest = new BitcoinUrlBuilder((await client.GetInvoicePaymentMethods(user.StoreId, invoice.Id)).Single(model =>
PaymentMethodId.Parse(model.PaymentMethod) ==
new PaymentMethodId("BTC", BitcoinPaymentType.Instance))
.PaymentLink, tester.ExplorerNode.Network);
halfPaymentTx = await tester.ExplorerNode.SendToAddressAsync(invoicePaymentRequest.Address, Money.Coins(invoicePaymentRequest.Amount.ToDecimal(MoneyUnit.BTC)/2m));
await user.AssertHasWebhookEvent(WebhookEventType.InvoiceCreated, (WebhookInvoiceEvent x)=> Assert.Equal(invoice.Id, x.InvoiceId));
await user.AssertHasWebhookEvent(WebhookEventType.InvoiceReceivedPayment,
(WebhookInvoiceReceivedPaymentEvent x) =>
{
Assert.Equal(invoice.Id, x.InvoiceId);
Assert.Contains(halfPaymentTx.ToString(), x.Payment.Id);
});
invoice = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest()
{
Amount = 0.01m,
Currency = "BTC"
});
await user.AssertHasWebhookEvent(WebhookEventType.InvoiceCreated, (WebhookInvoiceEvent x)=> Assert.Equal(invoice.Id, x.InvoiceId));
await client.MarkInvoiceStatus(user.StoreId, invoice.Id, new MarkInvoiceStatusRequest() { Status = InvoiceStatus.Invalid});
await user.AssertHasWebhookEvent(WebhookEventType.InvoiceInvalid, (WebhookInvoiceEvent x)=> Assert.Equal(invoice.Id, x.InvoiceId));
//payment request webhook test
var pr = await client.CreatePaymentRequest(user.StoreId, new CreatePaymentRequestRequest()
{
Amount = 100m,
Currency = "USD",
Title = "test pr",
//TODO: this is a bug, we should not have these props in create request
StoreId = user.StoreId,
FormResponse = new JObject(),
//END todo
Description = "lala baba"
});
await user.AssertHasWebhookEvent(WebhookEventType.PaymentRequestCreated, (WebhookPaymentRequestEvent x)=> Assert.Equal(pr.Id, x.PaymentRequestId));
pr = await client.UpdatePaymentRequest(user.StoreId, pr.Id,
new UpdatePaymentRequestRequest() { Title = "test pr updated", Amount = 100m,
Currency = "USD",
//TODO: this is a bug, we should not have these props in create request
StoreId = user.StoreId,
FormResponse = new JObject(),
//END todo
Description = "lala baba"});
await user.AssertHasWebhookEvent(WebhookEventType.PaymentRequestUpdated, (WebhookPaymentRequestEvent x)=> Assert.Equal(pr.Id, x.PaymentRequestId));
var inv = await client.PayPaymentRequest(user.StoreId, pr.Id, new PayPaymentRequestRequest() {});
await client.MarkInvoiceStatus(user.StoreId, inv.Id, new MarkInvoiceStatusRequest() { Status = InvoiceStatus.Settled});
await user.AssertHasWebhookEvent(WebhookEventType.PaymentRequestStatusChanged, (WebhookPaymentRequestEvent x)=>
{
Assert.Equal(PaymentRequestData.PaymentRequestStatus.Completed, x.Status);
Assert.Equal(pr.Id, x.PaymentRequestId);
});
await client.ArchivePaymentRequest(user.StoreId, pr.Id);
await user.AssertHasWebhookEvent(WebhookEventType.PaymentRequestArchived, (WebhookPaymentRequestEvent x)=> Assert.Equal(pr.Id, x.PaymentRequestId));
//payoyt webhooks test
var payout = await client.CreatePayout(user.StoreId,
new CreatePayoutThroughStoreRequest()
{
Amount = 0.0001m,
Destination = (await tester.ExplorerNode.GetNewAddressAsync()).ToString(),
Approved = true,
PaymentMethod = "BTC"
});
await user.AssertHasWebhookEvent(WebhookEventType.PayoutCreated, (WebhookPayoutEvent x)=> Assert.Equal(payout.Id, x.PayoutId));
await client.MarkPayout(user.StoreId, payout.Id, new MarkPayoutRequest(){ State = PayoutState.AwaitingApproval});
await user.AssertHasWebhookEvent(WebhookEventType.PayoutUpdated, (WebhookPayoutEvent x)=>
{
Assert.Equal(payout.Id, x.PayoutId);
Assert.Equal(PayoutState.AwaitingApproval, x.PayoutState);
});
await client.ApprovePayout(user.StoreId, payout.Id, new ApprovePayoutRequest(){});
await user.AssertHasWebhookEvent(WebhookEventType.PayoutApproved, (WebhookPayoutEvent x)=>
{
Assert.Equal(payout.Id, x.PayoutId);
Assert.Equal(PayoutState.AwaitingPayment, x.PayoutState);
});
await client.CancelPayout(user.StoreId, payout.Id );
await user.AssertHasWebhookEvent(WebhookEventType.PayoutUpdated, (WebhookPayoutEvent x)=>
{
Assert.Equal(payout.Id, x.PayoutId);
Assert.Equal(PayoutState.Cancelled, x.PayoutState);
});
}
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]
public async Task InvoiceFlowThroughDifferentStatesCorrectly()
@ -2102,18 +2274,18 @@ namespace BTCPayServer.Tests
});
// Test on the webhooks
user.AssertHasWebhookEvent<WebhookInvoiceSettledEvent>(WebhookEventType.InvoiceSettled,
await user.AssertHasWebhookEvent<WebhookInvoiceSettledEvent>(WebhookEventType.InvoiceSettled,
c =>
{
Assert.False(c.ManuallyMarked);
Assert.True(c.OverPaid);
});
user.AssertHasWebhookEvent<WebhookInvoiceProcessingEvent>(WebhookEventType.InvoiceProcessing,
await user.AssertHasWebhookEvent<WebhookInvoiceProcessingEvent>(WebhookEventType.InvoiceProcessing,
c =>
{
Assert.True(c.OverPaid);
});
user.AssertHasWebhookEvent<WebhookInvoiceReceivedPaymentEvent>(WebhookEventType.InvoiceReceivedPayment,
await user.AssertHasWebhookEvent<WebhookInvoiceReceivedPaymentEvent>(WebhookEventType.InvoiceReceivedPayment,
c =>
{
Assert.False(c.AfterExpiration);
@ -2123,7 +2295,7 @@ namespace BTCPayServer.Tests
Assert.StartsWith(txId.ToString(), c.Payment.Id);
});
user.AssertHasWebhookEvent<WebhookInvoicePaymentSettledEvent>(WebhookEventType.InvoicePaymentSettled,
await user.AssertHasWebhookEvent<WebhookInvoicePaymentSettledEvent>(WebhookEventType.InvoicePaymentSettled,
c =>
{
Assert.False(c.AfterExpiration);
@ -2332,7 +2504,7 @@ namespace BTCPayServer.Tests
var setting = await ctx.Settings.FirstOrDefaultAsync(c => c.Id == id);
if (setting != null) ctx.Settings.Remove(setting);
// create legacy policies setting that needs migration
setting = new SettingData { Id = id, Value = JObject.Parse("{\"RootAppId\": null, \"RootAppType\": 1, \"Experimental\": false, \"PluginSource\": null, \"LockSubscription\": false, \"DisableSSHService\": false, \"PluginPreReleases\": false, \"BlockExplorerLinks\": [],\"DomainToAppMapping\": [{\"AppId\": \"87kj5yKay8mB4UUZcJhZH5TqDKMD3CznjwLjiu1oYZXe\", \"Domain\": \"donate.nicolas-dorier.com\", \"AppType\": 0}], \"CheckForNewVersions\": false, \"AllowHotWalletForAll\": false, \"RequiresConfirmedEmail\": false, \"DiscourageSearchEngines\": false, \"DisableInstantNotifications\": false, \"DisableNonAdminCreateUserApi\": false, \"AllowHotWalletRPCImportForAll\": false, \"AllowLightningInternalNodeForAll\": false, \"DisableStoresToUseServerEmailSettings\": false}").ToString() };
setting = new SettingData { Id = id, Value = JObject.Parse("{\"RootAppId\": null, \"RootAppType\": 1, \"Experimental\": false, \"PluginSource\": null, \"LockSubscription\": false, \"DisableSSHService\": false, \"PluginPreReleases\": false, \"BlockExplorerLinks\": [],\"DomainToAppMapping\": [{\"AppId\": \"87kj5yKay8mB4UUZcJhZH5TqDKMD3CznjwLjiu1oYZXe\", \"Domain\": \"donate.nicolas-dorier.com\", \"AppType\": 0}], \"CheckForNewVersions\": false, \"AllowHotWalletForAll\": false, \"RequiresConfirmedEmail\": false, \"DiscourageSearchEngines\": false, \"DisableNonAdminCreateUserApi\": false, \"AllowHotWalletRPCImportForAll\": false, \"AllowLightningInternalNodeForAll\": false, \"DisableStoresToUseServerEmailSettings\": false}").ToString() };
ctx.Settings.Add(setting);
await ctx.SaveChangesAsync();
}
@ -2610,7 +2782,7 @@ namespace BTCPayServer.Tests
await tester.StartAsync();
var acc = tester.NewAccount();
acc.GrantAccess(true);
await acc.GrantAccessAsync(true);
var settings = tester.PayTester.GetService<SettingsRepository>();
var emailSenderFactory = tester.PayTester.GetService<EmailSenderFactory>();
@ -2635,7 +2807,7 @@ namespace BTCPayServer.Tests
Assert.Equal("admin@admin.com", (await Assert.IsType<ServerEmailSender>(await emailSenderFactory.GetEmailSender()).GetEmailSettings()).Login);
Assert.Null(await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings());
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIStoresController>().StoreEmailSettings(acc.StoreId, new EmailsViewModel(new EmailSettings()
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIStoresController>().StoreEmailSettings(acc.StoreId, new EmailsViewModel(new EmailSettings
{
From = "store@store.com",
Login = "store@store.com",
@ -2813,10 +2985,10 @@ namespace BTCPayServer.Tests
}
[Fact]
[Trait("Selenium", "Selenium")]
[Trait("Integration", "Integration")]
public async Task CanCreateReports()
{
using var tester = CreateServerTester();
using var tester = CreateServerTester(newDb: true);
tester.ActivateLightning();
tester.DeleteStore = false;
await tester.StartAsync();
@ -2914,6 +3086,55 @@ namespace BTCPayServer.Tests
.ToDictionary(d => d.Key, r => r.Sum(d => d[countIndex].Value<int>()));
Assert.Equal(8, itemsCount["green-tea"]);
Assert.Equal(1, itemsCount["black-tea"]);
await acc.ImportOldInvoices();
var date2018 = new DateTimeOffset(2018, 1, 1, 0, 0, 0, TimeSpan.Zero);
report = await GetReport(acc, new() { ViewName = "Payments", TimePeriod = new TimePeriod() { From = date2018, To = date2018 + TimeSpan.FromDays(365) } });
var invoiceIdIndex = report.GetIndex("InvoiceId");
var oldPaymentsCount = report.Data.Count(d => d[invoiceIdIndex].Value<string>() == "Q7RqoHLngK9svM4MgRyi9y");
Assert.Equal(8, oldPaymentsCount); // 10 payments, but 2 unaccounted
var addr = await tester.ExplorerNode.GetNewAddressAsync();
// Two invoices get refunded
for (int i = 0; i < 2; i++)
{
var inv = await client.CreateInvoice(acc.StoreId, new CreateInvoiceRequest() { Amount = 10m, Currency = "USD" });
await acc.PayInvoice(inv.Id);
await client.MarkInvoiceStatus(acc.StoreId, inv.Id, new MarkInvoiceStatusRequest() { Status = InvoiceStatus.Settled });
var refund = await client.RefundInvoice(acc.StoreId, inv.Id, new RefundInvoiceRequest() { RefundVariant = RefundVariant.Fiat, PaymentMethod = "BTC" });
async Task AssertData(string currency, decimal awaiting, decimal limit, decimal completed, bool fullyPaid)
{
report = await GetReport(acc, new() { ViewName = "Refunds" });
var currencyIndex = report.GetIndex("Currency");
var awaitingIndex = report.GetIndex("Awaiting");
var fullyPaidIndex = report.GetIndex("FullyPaid");
var completedIndex = report.GetIndex("Completed");
var limitIndex = report.GetIndex("Limit");
var d = Assert.Single(report.Data.Where(d => d[report.GetIndex("InvoiceId")].Value<string>() == inv.Id));
Assert.Equal(fullyPaid, (bool)d[fullyPaidIndex]);
Assert.Equal(currency, d[currencyIndex].Value<string>());
Assert.Equal(completed, (((JObject)d[completedIndex])["v"]).Value<decimal>());
Assert.Equal(awaiting, (((JObject)d[awaitingIndex])["v"]).Value<decimal>());
Assert.Equal(limit, (((JObject)d[limitIndex])["v"]).Value<decimal>());
}
await AssertData("USD", awaiting: 0.0m, limit: 10.0m, completed: 0.0m, fullyPaid: false);
var payout = await client.CreatePayout(refund.Id, new CreatePayoutRequest() { Destination = addr.ToString(), PaymentMethod = "BTC" });
await AssertData("USD", awaiting: 10.0m, limit: 10.0m, completed: 0.0m, fullyPaid: false);
await client.ApprovePayout(acc.StoreId, payout.Id, new ApprovePayoutRequest());
await AssertData("USD", awaiting: 10.0m, limit: 10.0m, completed: 0.0m, fullyPaid: false);
if (i == 0)
{
await client.MarkPayoutPaid(acc.StoreId, payout.Id);
await AssertData("USD", awaiting: 0.0m, limit: 10.0m, completed: 10.0m, fullyPaid: true);
}
if (i == 1)
{
await client.CancelPayout(acc.StoreId, payout.Id);
await AssertData("USD", awaiting: 0.0m, limit: 10.0m, completed: 0.0m, fullyPaid: false);
}
}
}
private async Task<StoreReportResponse> GetReport(TestAccount acc, StoreReportRequest req)

@ -18,7 +18,6 @@ using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.DevTools.V100.Network;
using OpenQA.Selenium.Support.UI;
using Xunit;
using Xunit.Abstractions;

@ -89,7 +89,7 @@ services:
- merchant_lnd
selenium:
image: selenium/standalone-chrome:101.0
image: selenium/standalone-chrome:125.0
extra_hosts:
- "tests:172.23.0.18"
expose:
@ -99,7 +99,7 @@ services:
custom:
nbxplorer:
image: nicolasdorier/nbxplorer:2.5.0
image: nicolasdorier/nbxplorer:2.5.4
restart: unless-stopped
ports:
- "32838:32838"
@ -163,7 +163,7 @@ services:
- "bitcoin_datadir:/data"
customer_lightningd:
image: btcpayserver/lightning:v23.08-dev
image: btcpayserver/lightning:v24.05
stop_signal: SIGKILL
restart: unless-stopped
environment:
@ -171,6 +171,7 @@ services:
LIGHTNINGD_CHAIN: "btc"
LIGHTNINGD_NETWORK: "regtest"
LIGHTNINGD_OPT: |
developer
bitcoin-datadir=/etc/bitcoin
bitcoin-rpcconnect=bitcoind
announce-addr=customer_lightningd:9735
@ -190,13 +191,15 @@ services:
- bitcoind
merchant_lightningd:
image: btcpayserver/lightning:v23.08-dev
image: btcpayserver/lightning:v24.05
stop_signal: SIGKILL
restart: unless-stopped
environment:
EXPOSE_TCP: "true"
LIGHTNINGD_CHAIN: "btc"
LIGHTNINGD_NETWORK: "regtest"
LIGHTNINGD_OPT: |
developer
bitcoin-datadir=/etc/bitcoin
bitcoin-rpcconnect=bitcoind
announce-addr=merchant_lightningd:9735
@ -214,6 +217,7 @@ services:
- "merchant_lightningd_datadir:/root/.lightning"
depends_on:
- bitcoind
postgres:
image: postgres:13.4
environment:
@ -224,7 +228,7 @@ services:
- "5432"
merchant_lnd:
image: btcpayserver/lnd:v0.17.3-beta
image: btcpayserver/lnd:v0.18.1-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@ -259,7 +263,7 @@ services:
- bitcoind
customer_lnd:
image: btcpayserver/lnd:v0.17.3-beta
image: btcpayserver/lnd:v0.18.1-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@ -306,27 +310,29 @@ services:
- "tor_datadir:/home/tor/.tor"
- "torrcdir:/usr/local/etc/tor"
- "tor_servicesdir:/var/lib/tor/hidden_services"
monerod:
image: btcpayserver/monero:0.18.2.2-5
restart: unless-stopped
container_name: xmr_monerod
entrypoint: sleep 999999
# entrypoint: monerod --fixed-difficulty 200 --rpc-bind-ip=0.0.0.0 --confirm-external-bind --rpc-bind-port=18081 --block-notify="/bin/sh ./scripts/notifier.sh -k -X GET https://host.docker.internal:14142/monerolikedaemoncallback/block?cryptoCode=xmr&hash=%s" --testnet --no-igd --hide-my-port --offline
volumes:
- "monero_data:/home/monero/.bitmonero"
ports:
- "18081:18081"
image: btcpayserver/monero:0.18.3.3
restart: unless-stopped
container_name: xmr_monerod
entrypoint: monerod --fixed-difficulty 200 --rpc-bind-ip=0.0.0.0 --confirm-external-bind --rpc-bind-port=18081 --block-notify="/bin/sh ./scripts/notifier.sh -k -X GET https://host.docker.internal:14142/monerolikedaemoncallback/block?cryptoCode=xmr&hash=%s" --testnet --no-igd --hide-my-port --offline --non-interactive
volumes:
- "monero_data:/home/monero/.bitmonero"
ports:
- "18081:18081"
monero_wallet:
image: btcpayserver/monero:0.18.2.2-5
image: btcpayserver/monero:0.18.3.3
restart: unless-stopped
container_name: xmr_wallet_rpc
entrypoint: monero-wallet-rpc --testnet --rpc-bind-ip=0.0.0.0 --disable-rpc-login --confirm-external-bind --rpc-bind-port=18082 --non-interactive --trusted-daemon --daemon-address=monerod:18081 --wallet-file=/wallet/wallet.keys --password-file=/wallet/password --tx-notify="/bin/sh ./scripts/notifier.sh -k -X GET https://host.docker.internal:14142/monerolikedaemoncallback/tx?cryptoCode=xmr&hash=%s"
entrypoint: monero-wallet-rpc --testnet --rpc-bind-ip=0.0.0.0 --disable-rpc-login --confirm-external-bind --rpc-bind-port=18082 --non-interactive --trusted-daemon --daemon-address=monerod:18081 --wallet-dir=/wallet --tx-notify="/bin/sh ./scripts/notifier.sh -k -X GET https://host.docker.internal:14142/monerolikedaemoncallback/tx?cryptoCode=xmr&hash=%s"
ports:
- "18082:18082"
volumes:
- "./monero_wallet:/wallet"
depends_on:
- monerod
- monerod
litecoind:
restart: unless-stopped
image: btcpayserver/litecoin:0.18.1

@ -86,7 +86,7 @@ services:
- merchant_lnd
selenium:
image: selenium/standalone-chrome:101.0
image: selenium/standalone-chrome:125.0
extra_hosts:
- "tests:172.23.0.18"
expose:
@ -96,7 +96,7 @@ services:
custom:
nbxplorer:
image: nicolasdorier/nbxplorer:2.5.0
image: nicolasdorier/nbxplorer:2.5.4
restart: unless-stopped
ports:
- "32838:32838"
@ -149,7 +149,7 @@ services:
- "bitcoin_datadir:/data"
customer_lightningd:
image: btcpayserver/lightning:v23.08-dev
image: btcpayserver/lightning:v24.05
stop_signal: SIGKILL
restart: unless-stopped
environment:
@ -157,6 +157,7 @@ services:
LIGHTNINGD_CHAIN: "btc"
LIGHTNINGD_NETWORK: "regtest"
LIGHTNINGD_OPT: |
developer
bitcoin-datadir=/etc/bitcoin
bitcoin-rpcconnect=bitcoind
announce-addr=customer_lightningd:9735
@ -176,13 +177,15 @@ services:
- bitcoind
merchant_lightningd:
image: btcpayserver/lightning:v23.08-dev
image: btcpayserver/lightning:v24.05
stop_signal: SIGKILL
restart: unless-stopped
environment:
EXPOSE_TCP: "true"
LIGHTNINGD_CHAIN: "btc"
LIGHTNINGD_NETWORK: "regtest"
LIGHTNINGD_OPT: |
developer
bitcoin-datadir=/etc/bitcoin
bitcoin-rpcconnect=bitcoind
announce-addr=merchant_lightningd:9735
@ -211,7 +214,7 @@ services:
- "5432"
merchant_lnd:
image: btcpayserver/lnd:v0.17.3-beta
image: btcpayserver/lnd:v0.18.1-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@ -248,7 +251,7 @@ services:
- bitcoind
customer_lnd:
image: btcpayserver/lnd:v0.17.3-beta
image: btcpayserver/lnd:v0.18.1-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"

@ -0,0 +1,2 @@
$container_id="$(docker ps -q --filter label=com.docker.compose.project=btcpayservertests --filter label=com.docker.compose.service=customer_lnd)"
docker exec -ti $container_id lncli --no-macaroons --rpcserver localhost:10008 $args

@ -0,0 +1,2 @@
$container_id="$(docker ps -q --filter label=com.docker.compose.project=btcpayservertests --filter label=com.docker.compose.service=merchant_lnd)"
docker exec -ti $container_id lncli --no-macaroons --rpcserver localhost:10008 $args

@ -0,0 +1,199 @@
#!/bin/bash
USERHOST="btcpay.local"
BASE="https://localhost:14142"
API_BASE="$BASE/api/v1"
PASSWORD="rockstar"
# Ensure we are in the script directory
cd "$(dirname "${BASH_SOURCE}")"
# Create admin user
admin_id=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'email': 'admin@$USERHOST', 'password': '$PASSWORD', 'isAdministrator': true }" \
"$API_BASE/users" | jq -r '.id')
printf "Admin ID: %s\n" "$admin_id"
# Create unlimited access API key
admin_api_key=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'permissions': ['unrestricted'], 'label': 'Unrestricted' }" \
--user "admin@$USERHOST:$PASSWORD" \
"$API_BASE/api-keys" | jq -r '.apiKey')
printf "Admin API Key: %s\n" "$admin_api_key"
printf "\n"
# Create Store Owner
owner_id=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'email': 'owner@$USERHOST', 'password': '$PASSWORD', 'isAdministrator': false }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/users" | jq -r '.id')
printf "Store Owner ID: %s\n" "$owner_id"
# Create Store Manager
manager_id=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'email': 'manager@$USERHOST', 'password': '$PASSWORD', 'isAdministrator': false }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/users" | jq -r '.id')
printf "Store Manager ID: %s\n" "$manager_id"
# Create Store Employee
employee_id=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'email': 'employee@$USERHOST', 'password': '$PASSWORD', 'isAdministrator': false }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/users" | jq -r '.id')
printf "Store Employee ID: %s\n" "$employee_id"
printf "\n"
# Create Satoshis Steaks store
res=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'name': 'Satoshis Steaks', 'checkoutType': 'V2', 'lightningAmountInSatoshi': true, 'onChainWithLnInvoiceFallback': true, 'playSoundOnPayment': true, 'defaultCurrency': 'EUR' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores")
store_id_satoshis_steaks=$( echo $res | jq -r '.id')
if [ -z "${store_id_satoshis_steaks}" ]; then
printf "Error creating Satoshis Steaks store: %s\n" "$res"
exit 1
fi
printf "Satoshis Steaks Store ID: %s\n" "$store_id_satoshis_steaks"
# Create Hot Wallet for Satoshis Steaks store
wallet_enabled_satoshis_steaks=$(curl -s -k -X PUT -H 'Content-Type: application/json' \
-d "{'enabled': true, 'config': 'tpubDC2mCtL7EPhey3qRgHXmKQRraxXgiuSTkHdJbDW22xLK1YMXy8jdEq7jx2UN5z1wU5xBWWZdSpAobG1bbZBTR4f8R3AjL31EzoexpngKUXM' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_satoshis_steaks/payment-methods/BTC-CHAIN")
# Create Internal Node connection for Satoshis Steaks store
ln_enabled_satoshis_steaks=$(curl -s -k -X PUT -H 'Content-Type: application/json' \
-d "{'enabled': true, 'config': { 'connectionString': 'Internal Node' } }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_satoshis_steaks/payment-methods/BTC-LN")
# LNURL settings
curl -s -k -X PUT -H 'Content-Type: application/json' \
-d "{'enabled': true, 'config': { 'lud12Enabled': true, 'useBech32Scheme': true } }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_satoshis_steaks/payment-methods/BTC-LNURL" >/dev/null 2>&1
# Fund Satoshis Steaks wallet
btcaddress_satoshis_steaks=$(curl -s -k -X GET -H 'Content-Type: application/json' \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_satoshis_steaks/payment-methods/onchain/BTC/wallet/address" | jq -r '.address')
./docker-bitcoin-cli.sh sendtoaddress "$btcaddress_satoshis_steaks" 6.15 >/dev/null 2>&1
printf "\n"
# Add store users to Satoshis Steaks store
curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'userId': '$owner_id', 'role': 'Owner' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_satoshis_steaks/users" >/dev/null 2>&1
curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'userId': '$manager_id', 'role': 'Manager' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_satoshis_steaks/users" >/dev/null 2>&1
curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'userId': '$employee_id', 'role': 'Employee' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_satoshis_steaks/users" >/dev/null 2>&1
# Create Nakamoto Nuggets store
store_id_nakamoto_nuggets=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'name': 'Nakamoto Nuggets', 'checkoutType': 'V2', 'lightningAmountInSatoshi': true, 'onChainWithLnInvoiceFallback': true, 'playSoundOnPayment': true }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores" | jq -r '.id')
printf "Nakamoto Nuggets Store ID: %s\n" "$store_id_nakamoto_nuggets"
# Create Hot Wallet for Nakamoto Nuggets store
# Seed: "resist camera spread better amazing cliff giraffe duty betray throw twelve father"
wallet_enabled_nakamoto_nuggets=$(curl -s -k -X PUT -H 'Content-Type: application/json' \
-d "{'enabled': true, 'config': 'tpubDD79XF4pzhmPSJ9AyUay9YbXAeD1c6nkUqC32pnKARJH6Ja5hGUfGc76V82ahXpsKqN6UcSGXMkzR34aZq4W23C6DAdZFaVrzWqzj24F8BC' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_nakamoto_nuggets/payment-methods/BTC-CHAIN")
# Connect Nakamoto Nuggets with Merchant LND Lightning node
curl -s -k -X PUT -H 'Content-Type: application/json' \
-d "{'enabled': true, 'config': { 'connectionString': 'type=lnd-rest;server=http://lnd:lnd@127.0.0.1:35531/;allowinsecure=true' }}" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_nakamoto_nuggets/payment-methods/BTC-LN" >/dev/null 2>&1
# LNURL settings
curl -s -k -X PUT -H 'Content-Type: application/json' \
-d "{'enabled': true, 'config': { 'lud12Enabled': true, 'useBech32Scheme': true } }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_nakamoto_nuggets/payment-methods/BTC-LNURL" >/dev/null 2>&1
# Add store users to Nakamoto Nuggets store
curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'userId': '$owner_id', 'role': 'Owner' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_nakamoto_nuggets/users" >/dev/null 2>&1
curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'userId': '$manager_id', 'role': 'Manager' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_nakamoto_nuggets/users" >/dev/null 2>&1
curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'userId': '$employee_id', 'role': 'Employee' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_nakamoto_nuggets/users" >/dev/null 2>&1
# Create Nakamoto Nuggets keypad app
keypad_app_id_nakamoto_nuggets=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'appName': 'Keypad', 'title': 'Keypad', 'defaultView': 'light', 'currency': 'SATS' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/{$store_id_nakamoto_nuggets}/apps/pos" | jq -r '.id')
printf "Nakamoto Nuggets Keypad POS ID: %s\n" "$keypad_app_id_nakamoto_nuggets"
# Create Nakamoto Nuggets cart app
cart_app_id_nakamoto_nuggets=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'appName': 'Cart', 'title': 'Cart', 'defaultView': 'cart', 'template': '[{\"id\":\"birell beer\",\"image\":\"https://i.imgur.com/r8N6rTU.png\",\"priceType\":\"Fixed\",\"price\":\"20\",\"title\":\"Birell Beer\",\"disabled\":false},{\"id\":\"flavoured birell beer\",\"image\":\"https://i.imgur.com/de43iUd.png\",\"priceType\":\"Fixed\",\"price\":\"20\",\"title\":\"Flavoured Birell Beer\",\"disabled\":false},{\"id\":\"wostok\",\"image\":\"https://i.imgur.com/gP6zqub.png\",\"priceType\":\"Fixed\",\"price\":\"25\",\"title\":\"Wostok\",\"disabled\":false},{\"id\":\"pilsner beer\",\"image\":\"https://i.imgur.com/M4EEaEP.png\",\"priceType\":\"Fixed\",\"price\":\"30\",\"title\":\"Pilsner Beer\",\"disabled\":false},{\"id\":\"club mate\",\"image\":\"https://i.imgur.com/H9p9Xwc.png\",\"priceType\":\"Fixed\",\"price\":\"35\",\"title\":\"Club Mate\",\"disabled\":false},{\"id\":\"seicha / selo / koka\",\"image\":\"https://i.imgur.com/ReW3RKe.png\",\"priceType\":\"Fixed\",\"price\":\"35\",\"title\":\"Seicha / Selo / Koka\",\"disabled\":false},{\"id\":\"limonada z kopanic\",\"image\":\"https://i.imgur.com/2Xb35Zs.png\",\"priceType\":\"Fixed\",\"price\":\"40\",\"title\":\"Limonada z Kopanic\",\"disabled\":false},{\"id\":\"mellow drink\",\"image\":\"https://i.imgur.com/ilDUWiP.png\",\"priceType\":\"Fixed\",\"price\":\"40\",\"title\":\"Mellow Drink\",\"disabled\":false},{\"id\":\"bacilli drink\",\"image\":\"https://i.imgur.com/3BsCLgG.png\",\"priceType\":\"Fixed\",\"price\":\"40\",\"title\":\"Bacilli Drink\",\"disabled\":false},{\"description\":\"\",\"id\":\"vincentka\",\"image\":\"https://i.imgur.com/99reAEg.png\",\"priceType\":\"Fixed\",\"price\":\"20\",\"title\":\"Vincentka\",\"disabled\":false,\"index\":\"-1\"},{\"id\":\"kinder bar\",\"image\":\"https://i.imgur.com/va9i6SQ.png\",\"priceType\":\"Fixed\",\"price\":\"20\",\"title\":\"Kinder bar\",\"disabled\":false},{\"id\":\"nutrend bar\",\"image\":\"https://i.imgur.com/zzdIup0.png\",\"priceType\":\"Fixed\",\"price\":\"15\",\"title\":\"Nutrend bar\",\"disabled\":false},{\"id\":\"yoghurt\",\"image\":\"https://i.imgur.com/biP4Dr8.png\",\"priceType\":\"Fixed\",\"price\":\"20\",\"title\":\"Yoghurt\",\"disabled\":false},{\"id\":\"mini magnum\",\"image\":\"https://i.imgur.com/tveN4Aa.png\",\"priceType\":\"Fixed\",\"price\":\"35\",\"title\":\"Mini Magnum\",\"disabled\":false},{\"description\":\"\",\"id\":\"nanuk do:pusy\",\"image\":\"https://i.imgur.com/EzZN6lV.png\",\"priceType\":\"Fixed\",\"price\":\"30\",\"title\":\"Nanuk DO:PUSY\",\"disabled\":false,\"index\":\"-1\"},{\"id\":\"alpro dessert\",\"image\":\"https://i.imgur.com/L0MHkcs.png\",\"priceType\":\"Fixed\",\"price\":\"30\",\"title\":\"Alpro dessert\",\"disabled\":false},{\"id\":\"mixitka bar\",\"image\":\"https://i.imgur.com/gHuTGK3.png\",\"priceType\":\"Fixed\",\"price\":\"30\",\"title\":\"Mixitka bar\",\"disabled\":false},{\"id\":\"instatni polivka\",\"image\":\"https://cdn.rohlik.cz/images/grocery/products/722313/722313-1598298944.jpg\",\"priceType\":\"Fixed\",\"price\":\"15\",\"title\":\"Instatni polivka\",\"disabled\":false},{\"id\":\"m&amp;s instatni polivka\",\"image\":\"https://i.imgur.com/Y8LCJbG.png\",\"priceType\":\"Fixed\",\"price\":\"60\",\"title\":\"M&amp;S instatni polivka\",\"disabled\":false}]' }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/{$store_id_nakamoto_nuggets}/apps/pos" | jq -r '.id')
printf "Nakamoto Nuggets Cart POS ID: %s\n" "$cart_app_id_nakamoto_nuggets"
# Fund Nakamoto Nuggets wallet
btcaddress_nakamoto_nuggets=$(curl -s -k -X GET -H 'Content-Type: application/json' \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_nakamoto_nuggets/payment-methods/onchain/BTC/wallet/address" | jq -r '.address')
./docker-bitcoin-cli.sh sendtoaddress "$btcaddress_nakamoto_nuggets" 6.15 >/dev/null 2>&1
printf "\n"
# Create External Lightning based store
store_id_externalln=$(curl -s -k -X POST -H 'Content-Type: application/json' \
-d "{'name': 'External Lightning (LND)', 'checkoutType': 'V2', 'lightningAmountInSatoshi': true, 'onChainWithLnInvoiceFallback': true }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores" | jq -r '.id')
printf "External Lightning Store ID: %s\n" "$store_id_externalln"
# Connect External Lightning based store with Customer LND Lightning node
curl -s -k -X PUT -H 'Content-Type: application/json' \
-d "{'enabled': true, 'config': { 'connectionString': 'type=lnd-rest;server=http://lnd:lnd@127.0.0.1:35532/;allowinsecure=true' } }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_externalln/payment-methods/BTC-LN" >/dev/null 2>&1
# LNURL settings
curl -s -k -X PUT -H 'Content-Type: application/json' \
-d "{'enabled': true, 'config': { 'lud12Enabled': true, 'useBech32Scheme': true } }" \
-H "Authorization: token $admin_api_key" \
"$API_BASE/stores/$store_id_externalln/payment-methods/BTC-LNURL" >/dev/null 2>&1
printf "\n"
# Mine some blocks
./docker-bitcoin-generate.sh 5 >/dev/null 2>&1

@ -22,18 +22,6 @@
<EmbeddedResource Remove="wwwroot\vendor\jquery-nice-select\**" />
<None Remove="Build\**" />
<None Remove="wwwroot\vendor\jquery-nice-select\**" />
<Content Update="Plugins\BoltcardBalance\Views\ScanCard.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Plugins\BoltcardFactory\Views\UpdateBoltcardFactory.cshtml">
<Pack>false</Pack>
</Content>
<Content Update="Plugins\BoltcardFactory\Views\ViewBoltcardFactory.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Plugins\BoltcardTopUp\Views\ScanCard.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Views\UIStorePullPayments\NewPullPayment.cshtml">
<Pack>false</Pack>
</Content>
@ -58,13 +46,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BTCPayServer.NTag424" Version="1.0.22" />
<PackageReference Include="BTCPayServer.NTag424" Version="1.0.23" />
<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.5.4" />
<PackageReference Include="CsvHelper" Version="15.0.5" />
<PackageReference Include="Dapper" Version="2.1.28" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.6.0" />
<PackageReference Include="CsvHelper" Version="32.0.3" />
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Fido2" Version="2.0.2" />
<PackageReference Include="Fido2.AspNet" Version="2.0.2" />
<PackageReference Include="LNURL" Version="0.0.34" />
@ -89,8 +77,8 @@
<PackageReference Include="TwentyTwenty.Storage.Azure" Version="2.12.1" />
<PackageReference Include="TwentyTwenty.Storage.Google" Version="2.12.1" />
<PackageReference Include="TwentyTwenty.Storage.Local" Version="2.12.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.6" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7" />
</ItemGroup>
<ItemGroup>

@ -26,16 +26,16 @@
{
@if (Model.Store != null)
{
<div class="accordion-item" permission="@Policies.CanModifyStoreSettings">
<div class="accordion-item" permission="@Policies.CanViewStoreSettings">
<div class="accordion-body">
<ul class="navbar-nav">
<li class="nav-item">
<li class="nav-item" permission="@Policies.CanModifyStoreSettings">
<a asp-area="" asp-controller="UIStores" asp-action="Dashboard" asp-route-storeId="@Model.Store.Id" class="nav-link @ViewData.IsActivePage(StoreNavPages.Dashboard)" id="StoreNav-Dashboard">
<vc:icon symbol="home"/>
<span>Dashboard</span>
</a>
</li>
<li class="nav-item">
<li class="nav-item" permission="@Policies.CanViewStoreSettings">
<a asp-area="" asp-controller="UIStores" asp-action="GeneralSettings" asp-route-storeId="@Model.Store.Id" class="nav-link @ViewData.IsActivePage(new [] {StoreNavPages.Rates, StoreNavPages.CheckoutAppearance, StoreNavPages.General, StoreNavPages.Tokens, StoreNavPages.Users, StoreNavPages.Webhooks, StoreNavPages.PayoutProcessors, StoreNavPages.Emails})" id="StoreNav-StoreSettings">
<vc:icon symbol="settings"/>
<span>Settings</span>
@ -115,7 +115,6 @@
</a>
</li>
}
</ul>
</div>
</div>
@ -252,7 +251,7 @@
{
<ul id="mainNavSettings" class="navbar-nav border-top p-3 px-lg-4">
<li class="nav-item" permission="@Policies.CanModifyServerSettings">
<a asp-area="" asp-controller="UIServer" asp-action="ListUsers" class="nav-link @ViewData.IsActivePage(ServerNavPages.Users) @ViewData.IsActivePage(ServerNavPages.Emails) @ViewData.IsActivePage(ServerNavPages.Policies) @ViewData.IsActivePage(ServerNavPages.Services) @ViewData.IsActivePage(ServerNavPages.Theme) @ViewData.IsActivePage(ServerNavPages.Maintenance) @ViewData.IsActivePage(ServerNavPages.Logs) @ViewData.IsActivePage(ServerNavPages.Files)" id="Nav-ServerSettings">
<a asp-area="" asp-controller="UIServer" asp-action="ListUsers" class="nav-link @ViewData.IsActivePage(ServerNavPages.Users) @ViewData.IsActivePage(ServerNavPages.Emails) @ViewData.IsActivePage(ServerNavPages.Policies) @ViewData.IsActivePage(ServerNavPages.Services) @ViewData.IsActivePage(ServerNavPages.Branding) @ViewData.IsActivePage(ServerNavPages.Maintenance) @ViewData.IsActivePage(ServerNavPages.Logs) @ViewData.IsActivePage(ServerNavPages.Files)" id="Nav-ServerSettings">
<vc:icon symbol="server-settings"/>
<span>Server Settings</span>
</a>
@ -297,6 +296,15 @@
</li>
</ul>
</li>
@if (!string.IsNullOrWhiteSpace(Model.ContactUrl))
{
<li class="nav-item">
<a href="@Model.ContactUrl" class="nav-link" id="Nav-ContactUs">
<vc:icon symbol="contact"/>
<span>Contact Us</span>
</a>
</li>
}
</ul>
}
</nav>

@ -29,6 +29,8 @@ namespace BTCPayServer.Components.MainNav
private readonly UserManager<ApplicationUser> _userManager;
private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary;
private readonly CustodianAccountRepository _custodianAccountRepository;
private readonly SettingsRepository _settingsRepository;
public PoliciesSettings PoliciesSettings { get; }
public MainNav(
AppService appService,
@ -38,6 +40,7 @@ namespace BTCPayServer.Components.MainNav
UserManager<ApplicationUser> userManager,
PaymentMethodHandlerDictionary paymentMethodHandlerDictionary,
CustodianAccountRepository custodianAccountRepository,
SettingsRepository settingsRepository,
PoliciesSettings policiesSettings)
{
_storeRepo = storeRepo;
@ -47,13 +50,19 @@ namespace BTCPayServer.Components.MainNav
_storesController = storesController;
_paymentMethodHandlerDictionary = paymentMethodHandlerDictionary;
_custodianAccountRepository = custodianAccountRepository;
_settingsRepository = settingsRepository;
PoliciesSettings = policiesSettings;
}
public async Task<IViewComponentResult> InvokeAsync()
{
var store = ViewContext.HttpContext.GetStoreData();
var vm = new MainNavViewModel { Store = store };
var serverSettings = await _settingsRepository.GetSettingAsync<ServerSettings>() ?? new ServerSettings();
var vm = new MainNavViewModel
{
Store = store,
ContactUrl = serverSettings.ContactUrl
};
#if ALTCOINS
vm.AltcoinsBuild = true;
#endif
@ -92,7 +101,5 @@ namespace BTCPayServer.Components.MainNav
}
private string UserId => _userManager.GetUserId(HttpContext.User);
public PoliciesSettings PoliciesSettings { get; }
}
}

@ -13,6 +13,7 @@ namespace BTCPayServer.Components.MainNav
public CustodianAccountData[] CustodianAccounts { get; set; }
public bool AltcoinsBuild { get; set; }
public int ArchivedAppsCount { get; set; }
public string ContactUrl { get; set; }
}
public class StoreApp

@ -34,34 +34,36 @@
}
else if (Model.Invoices.Any())
{
<table class="table table-hover mt-3 mb-0">
<thead>
<tr>
<th class="w-125px">Date</th>
<th class="text-nowrap">Invoice Id</th>
<th>Status</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
@foreach (var invoice in Model.Invoices)
{
<div class="table-responsive mt-3 mb-0">
<table class="table table-hover mb-0">
<thead>
<tr>
<td>@invoice.Date.ToTimeAgo()</td>
<td>
<a asp-controller="UIInvoice" asp-action="Invoice" asp-route-invoiceId="@invoice.InvoiceId" class="text-break">@invoice.InvoiceId</a>
</td>
<td>
<vc:invoice-status state="invoice.Status" payments="invoice.Details.Payments" invoice-id="@invoice.InvoiceId"
is-archived="invoice.Details.Archived" has-refund="invoice.HasRefund" />
</td>
<td class="text-end">
<span data-sensitive>@DisplayFormatter.Currency(invoice.Amount, invoice.Currency)</span>
</td>
<th class="w-125px">Date</th>
<th class="text-nowrap">Invoice Id</th>
<th>Status</th>
<th class="text-end">Amount</th>
</tr>
}
</tbody>
</table>
</thead>
<tbody>
@foreach (var invoice in Model.Invoices)
{
<tr>
<td>@invoice.Date.ToTimeAgo()</td>
<td>
<a asp-controller="UIInvoice" asp-action="Invoice" asp-route-invoiceId="@invoice.InvoiceId" class="text-break">@invoice.InvoiceId</a>
</td>
<td>
<vc:invoice-status state="invoice.Status" payments="invoice.Details.Payments" invoice-id="@invoice.InvoiceId"
is-archived="invoice.Details.Archived" has-refund="invoice.HasRefund" />
</td>
<td class="text-end">
<span data-sensitive>@DisplayFormatter.Currency(invoice.Amount, invoice.Currency)</span>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{

@ -31,61 +31,63 @@
}
else if (Model.Transactions.Any())
{
<table class="table table-hover mt-3 mb-0">
<thead>
<tr>
<th class="w-125px">Date</th>
<th>Transaction</th>
<th>Labels</th>
<th class="text-end">Amount</th>
</tr>
</thead>
<tbody>
@foreach (var tx in Model.Transactions)
{
<div class="table-responsive mt-3 mb-0">
<table class="table table-hover mb-0">
<thead>
<tr>
<td>@tx.Timestamp.ToTimeAgo()</td>
<td>
<vc:truncate-center text="@tx.Id" link="@tx.Link" classes="truncate-center-id" />
</td>
<td>
@if (tx.Labels.Any())
{
<div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var label in tx.Labels)
{
<div class="transaction-label" style="--label-bg:@label.Color;--label-fg:@label.TextColor">
<span>@label.Text</span>
@if (!string.IsNullOrEmpty(label.Link))
{
<a class="transaction-label-info transaction-details-icon" href="@label.Link"
target="_blank" rel="noreferrer noopener" title="@label.Tooltip" data-bs-html="true"
data-bs-toggle="tooltip" data-bs-custom-class="transaction-label-tooltip">
<vc:icon symbol="info" />
</a>
}
</div>
}
</div>
}
</td>
@if (tx.Positive)
{
<td class="text-end text-success">
<span data-sensitive>@DisplayFormatter.Currency(tx.Balance, tx.Currency)</span>
</td>
}
else
{
<td class="text-end text-danger">
<span data-sensitive>@DisplayFormatter.Currency(tx.Balance, tx.Currency)</span>
</td>
}
<th class="w-125px">Date</th>
<th>Transaction</th>
<th>Labels</th>
<th class="text-end">Amount</th>
</tr>
}
</tbody>
</table>
</thead>
<tbody>
@foreach (var tx in Model.Transactions)
{
<tr>
<td>@tx.Timestamp.ToTimeAgo()</td>
<td>
<vc:truncate-center text="@tx.Id" link="@tx.Link" classes="truncate-center-id" />
</td>
<td>
@if (tx.Labels.Any())
{
<div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var label in tx.Labels)
{
<div class="transaction-label" style="--label-bg:@label.Color;--label-fg:@label.TextColor">
<span>@label.Text</span>
@if (!string.IsNullOrEmpty(label.Link))
{
<a class="transaction-label-info transaction-details-icon" href="@label.Link"
target="_blank" rel="noreferrer noopener" title="@label.Tooltip" data-bs-html="true"
data-bs-toggle="tooltip" data-bs-custom-class="transaction-label-tooltip">
<vc:icon symbol="info" />
</a>
}
</div>
}
</div>
}
</td>
@if (tx.Positive)
{
<td class="text-end text-success">
<span data-sensitive>@DisplayFormatter.Currency(tx.Balance, tx.Currency)</span>
</td>
}
else
{
<td class="text-end text-danger">
<span data-sensitive>@DisplayFormatter.Currency(tx.Balance, tx.Currency)</span>
</td>
}
</tr>
}
</tbody>
</table>
</div>
}
else
{

@ -2,6 +2,7 @@
@using BTCPayServer.Client
@using BTCPayServer.Components.MainLogo
@using BTCPayServer.Services
@using BTCPayServer.Views.Server
@using BTCPayServer.Views.Stores
@inject BTCPayServerEnvironment Env
@inject IFileService FileService
@ -53,7 +54,7 @@ else
@foreach (var option in Model.Options)
{
<li>
<a asp-controller="UIStores" asp-action="Index" asp-route-storeId="@option.Value" class="dropdown-item@(option.Selected ? " active" : "")" id="StoreSelectorMenuItem-@option.Value">@StoreName(option.Text)</a>
<a asp-controller="UIStores" asp-action="Index" asp-route-storeId="@option.Value" class="dropdown-item@(option.Selected && ViewData.IsActivePage(ServerNavPages.Stores) != "active" ? " active" : "")" id="StoreSelectorMenuItem-@option.Value">@StoreName(option.Text)</a>
</li>
}
@if (Model.Options.Any())
@ -66,6 +67,10 @@ else
<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>
}
@*
<li permission="@Policies.CanModifyServerSettings"><hr class="dropdown-divider"></li>
<li permission="@Policies.CanModifyServerSettings"><a asp-controller="UIServer" asp-action="ListStores" class="dropdown-item @ViewData.IsActivePage(ServerNavPages.Stores)" id="StoreSelectorAdminStores">Admin Store Overview</a></li>
*@
</ul>
</div>
</div>

@ -4,23 +4,23 @@
var isTruncated = !string.IsNullOrEmpty(Model.Start) && !string.IsNullOrEmpty(Model.End);
@if (Model.Copy) classes += " truncate-center--copy";
@if (Model.Elastic) classes += " truncate-center--elastic";
var prefix = Model.IsVue ? ":" : "";
}
<span class="truncate-center @classes"@(!string.IsNullOrEmpty(Model.Id) ? $"id={Model.Id}" : null) data-text=@Safe.Json(Model.Text)>
<span class="truncate-center @classes" id="@Model.Id" data-text="@Model.Text">
@if (Model.IsVue)
{
<span class="truncate-center-truncated" data-bs-toggle="tooltip" :title=@Safe.Json(Model.Text)>
<span class="truncate-center-truncated" data-bs-toggle="tooltip" :title="@Model.Text">
@if (Model.Elastic)
{
<span class="truncate-center-start" v-text=@Safe.Json(Model.Text)></span>
<span class="truncate-center-start" v-text="@Model.Text"></span>
}
else
{
<span class="truncate-center-start" v-text=@Safe.Json($"{Model.Text}.slice(0, {Model.Padding})")></span>
<span>…</span>
<span class="truncate-center-start" v-text="@(Model.Text).length > 2 * @(Model.Padding) ? (@(Model.Text).slice(0, @(Model.Padding)) + '…') : @(Model.Text)"></span>
}
<span class="truncate-center-end" v-text=@Safe.Json($"{Model.Text}.slice(-{Model.Padding})")></span>
<span class="truncate-center-end" v-text="@(Model.Text).slice(-@(Model.Padding))" v-if="@(Model.Text).length > 2 * @(Model.Padding)"></span>
</span>
<span class="truncate-center-text" v-text=@Safe.Json(Model.Text)></span>
<span class="truncate-center-text" v-text="@Model.Text"></span>
}
else
{
@ -35,13 +35,13 @@
}
@if (Model.Copy)
{
<button type="button" class="btn btn-link p-0" @(Model.IsVue ? ":" : string.Empty)data-clipboard=@Safe.Json(Model.Text)>
<button type="button" class="btn btn-link p-0" @(prefix)data-clipboard="@Model.Text">
<vc:icon symbol="copy" />
</button>
}
@if (!string.IsNullOrEmpty(Model.Link))
{
<a @(Model.IsVue ? ":" : "")href="@Model.Link" rel="noreferrer noopener" target="_blank">
<a @(prefix)href="@Model.Link" rel="noreferrer noopener" target="_blank">
<vc:icon symbol="info" />
</a>
}

@ -213,6 +213,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
[HttpDelete("~/api/v1/apps/{appId}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> DeleteApp(string appId)
{
var app = await _appService.GetApp(appId, null, includeArchived: true);
@ -238,7 +239,7 @@ namespace BTCPayServer.Controllers.Greenfield
return new CrowdfundSettings
{
Title = request.Title?.Trim(),
Title = request.Title?.Trim() ?? request.AppName,
Enabled = request.Enabled ?? true,
EnforceTargetAmount = request.EnforceTargetAmount ?? false,
StartDate = request.StartDate?.UtcDateTime,
@ -272,8 +273,9 @@ namespace BTCPayServer.Controllers.Greenfield
{
return new PointOfSaleSettings
{
Title = request.Title,
Title = request.Title ?? request.AppName,
DefaultView = (PosViewType)request.DefaultView,
ShowItems = request.ShowItems,
ShowCustomAmount = request.ShowCustomAmount,
ShowDiscount = request.ShowDiscount,
ShowSearch = request.ShowSearch,
@ -335,6 +337,7 @@ namespace BTCPayServer.Controllers.Greenfield
Created = appData.Created,
Title = settings.Title,
DefaultView = settings.DefaultView.ToString(),
ShowItems = settings.ShowItems,
ShowCustomAmount = settings.ShowCustomAmount,
ShowDiscount = settings.ShowDiscount,
ShowSearch = settings.ShowSearch,

@ -326,7 +326,7 @@ namespace BTCPayServer.Controllers.Greenfield
if (result == null)
{
return this.CreateAPIError(404, "trade-not-found",
$"Could not find the the trade with ID {tradeId} on {custodianAccount.Name}");
$"Could not find the trade with ID {tradeId} on {custodianAccount.Name}");
}
return Ok(ToModel(result, accountId, custodianAccount.CustodianCode));
}

@ -401,6 +401,14 @@ namespace BTCPayServer.Controllers.Greenfield
var accounting = invoicePaymentMethod.Calculate();
var cryptoPaid = accounting.Paid;
var dueAmount = accounting.TotalDue;
// If no payment, but settled and marked, assume it has been fully paid
if (cryptoPaid is 0 && invoice is { Status: InvoiceStatusLegacy.Confirmed or InvoiceStatusLegacy.Complete, ExceptionStatus: InvoiceExceptionStatus.Marked })
{
cryptoPaid = accounting.TotalDue;
dueAmount = 0;
}
var cdCurrency = _currencyNameTable.GetCurrencyData(invoice.Currency, true);
var paidCurrency = Math.Round(cryptoPaid * invoicePaymentMethod.Rate, cdCurrency.Divisibility);
var rateResult = await _rateProvider.FetchRate(
@ -468,7 +476,6 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateValidationError(ModelState);
}
var dueAmount = accounting.TotalDue;
createPullPayment.Currency = cryptoCode;
createPullPayment.Amount = Math.Round(paidAmount - dueAmount, appliedDivisibility);
createPullPayment.AutoApproveClaims = true;

@ -168,6 +168,8 @@ namespace BTCPayServer.Controllers.Greenfield
Status = Client.Models.PaymentRequestData.PaymentRequestStatus.Pending,
Created = DateTimeOffset.UtcNow
};
request.FormResponse = null;
request.StoreId = storeId;
pr.SetBlob(request);
pr = await _paymentRequestRepository.CreateOrUpdatePaymentRequest(pr);
return Ok(FromModel(pr));
@ -196,6 +198,9 @@ namespace BTCPayServer.Controllers.Greenfield
}
var updatedPr = pr.First();
var blob = updatedPr.GetBlob();
request.FormResponse = blob.FormResponse;
request.StoreId = storeId;
updatedPr.SetBlob(request);
return Ok(FromModel(await _paymentRequestRepository.CreateOrUpdatePaymentRequest(updatedPr)));

@ -14,7 +14,6 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.NTag424;
using BTCPayServer.Payments;
using BTCPayServer.Security;
@ -29,7 +28,6 @@ using Microsoft.Extensions.Logging;
using NBitcoin.DataEncoders;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Org.BouncyCastle.Bcpg.OpenPgp;
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
namespace BTCPayServer.Controllers.Greenfield
@ -49,7 +47,6 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly IAuthorizationService _authorizationService;
private readonly SettingsRepository _settingsRepository;
private readonly BTCPayServerEnvironment _env;
private readonly Logs _logs;
public GreenfieldPullPaymentController(PullPaymentHostedService pullPaymentService,
LinkGenerator linkGenerator,
@ -60,7 +57,7 @@ namespace BTCPayServer.Controllers.Greenfield
BTCPayNetworkProvider btcPayNetworkProvider,
IAuthorizationService authorizationService,
SettingsRepository settingsRepository,
BTCPayServerEnvironment env, Logs logs)
BTCPayServerEnvironment env)
{
_pullPaymentService = pullPaymentService;
_linkGenerator = linkGenerator;
@ -72,7 +69,6 @@ namespace BTCPayServer.Controllers.Greenfield
_authorizationService = authorizationService;
_settingsRepository = settingsRepository;
_env = env;
_logs = logs;
}
[HttpGet("~/api/v1/stores/{storeId}/pull-payments")]
@ -199,8 +195,6 @@ namespace BTCPayServer.Controllers.Greenfield
{
if (pullPaymentId is null)
return PullPaymentNotFound();
this._logs.PayServer.LogInformation($"RegisterBoltcard: onExisting queryParam: {onExisting}");
this._logs.PayServer.LogInformation($"{JsonConvert.SerializeObject(request)}");
var pp = await _pullPaymentService.GetPullPayment(pullPaymentId, false);
if (pp is null)
return PullPaymentNotFound();
@ -246,13 +240,7 @@ namespace BTCPayServer.Controllers.Greenfield
_ => request.OnExisting
};
this._logs.PayServer.LogInformation($"After");
this._logs.PayServer.LogInformation($"{JsonConvert.SerializeObject(request)}");
var version = await _dbContextFactory.LinkBoltcardToPullPayment(pullPaymentId, issuerKey, request.UID, request.OnExisting);
this._logs.PayServer.LogInformation($"Version: " + version);
this._logs.PayServer.LogInformation($"ID: " + Encoders.Hex.EncodeData(issuerKey.GetId(request.UID)));
var keys = issuerKey.CreatePullPaymentCardKey(request.UID, version, pullPaymentId).DeriveBoltcardKeys(issuerKey);
var boltcardUrl = Url.Action(nameof(UIBoltcardController.GetWithdrawRequest), "UIBoltcard");
@ -269,9 +257,6 @@ namespace BTCPayServer.Controllers.Greenfield
K3 = Encoders.Hex.EncodeData(keys.K3.ToBytes()).ToUpperInvariant(),
K4 = Encoders.Hex.EncodeData(keys.K4.ToBytes()).ToUpperInvariant(),
};
this._logs.PayServer.LogInformation($"Response");
this._logs.PayServer.LogInformation($"{JsonConvert.SerializeObject(resp)}");
return Ok(resp);
}
@ -438,7 +423,8 @@ namespace BTCPayServer.Controllers.Greenfield
Destination = destination.destination,
PullPaymentId = pullPaymentId,
Value = request.Amount,
PaymentMethodId = paymentMethodId
PaymentMethodId = paymentMethodId,
StoreId = pp.StoreId
});
return HandleClaimResult(result);

@ -1,4 +1,5 @@
#nullable enable
using System;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
@ -8,6 +9,8 @@ using BTCPayServer.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using AuthenticationSchemes = BTCPayServer.Abstractions.Constants.AuthenticationSchemes;
using LightningAddressData = BTCPayServer.Client.Models.LightningAddressData;
@ -31,12 +34,13 @@ namespace BTCPayServer.Controllers.Greenfield
var blob = data.GetBlob();
if (blob is null)
return new LightningAddressData();
return new LightningAddressData()
return new LightningAddressData
{
Username = data.Username,
Max = blob.Max,
Min = blob.Min,
CurrencyCode = blob.CurrencyCode
CurrencyCode = blob.CurrencyCode,
InvoiceMetadata = blob.InvoiceMetadata
};
}
@ -83,16 +87,17 @@ namespace BTCPayServer.Controllers.Greenfield
ModelState.AddModelError(nameof(data.Min), "Minimum must be greater than 0 if provided.");
return this.CreateValidationError(ModelState);
}
if (await _lightningAddressService.Set(new Data.LightningAddressData()
if (await _lightningAddressService.Set(new Data.LightningAddressData
{
StoreDataId = storeId,
Username = username
}.SetBlob(new LightningAddressDataBlob()
}.SetBlob(new LightningAddressDataBlob
{
Max = data.Max,
Min = data.Min,
CurrencyCode = data.CurrencyCode
CurrencyCode = data.CurrencyCode,
InvoiceMetadata = data.InvoiceMetadata
})))
{
return await GetStoreLightningAddress(storeId, username);

@ -24,7 +24,7 @@ namespace BTCPayServer.Controllers.Greenfield
_storeRepository = storeRepository;
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/roles")]
public async Task<IActionResult> GetStoreRoles(string storeId)
{
@ -34,7 +34,6 @@ namespace BTCPayServer.Controllers.Greenfield
: Ok(FromModel(await _storeRepository.GetStoreRoles(storeId, false, false)));
}
private List<RoleData> FromModel(StoreRepository.StoreRole[] data)
{
return data.Select(r => new RoleData() {Role = r.Role, Id = r.Id, Permissions = r.Permissions, IsServerRole = r.IsServerRole}).ToList();

@ -27,14 +27,15 @@ namespace BTCPayServer.Controllers.Greenfield
_storeRepository = storeRepository;
_userManager = userManager;
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/users")]
public IActionResult GetStoreUsers()
{
var store = HttpContext.GetStoreData();
return store == null ? StoreNotFound() : Ok(FromModel(store));
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpDelete("~/api/v1/stores/{storeId}/users/{idOrEmail}")]
public async Task<IActionResult> RemoveStoreUser(string storeId, string idOrEmail)

@ -185,7 +185,7 @@ namespace BTCPayServer.Controllers.Greenfield
RequiresEmailConfirmation = policies.RequiresConfirmedEmail,
RequiresApproval = policies.RequiresUserApproval,
Created = DateTimeOffset.UtcNow,
Approved = !anyAdmin && isAdmin // auto-approve first admin
Approved = isAdmin // auto-approve first admin and users created by an admin
};
var passwordValidation = await this._passwordValidator.ValidateAsync(_userManager, user, request.Password);
if (!passwordValidation.Succeeded)
@ -214,7 +214,8 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateValidationError(ModelState);
}
if (request.IsAdministrator is true)
var isNewAdmin = request.IsAdministrator is true;
if (isNewAdmin)
{
if (!anyAdmin)
{
@ -233,7 +234,21 @@ namespace BTCPayServer.Controllers.Greenfield
await _settingsRepository.FirstAdminRegistered(policies, _options.UpdateUrl != null, _options.DisableRegistration, Logs);
}
}
_eventAggregator.Publish(new UserRegisteredEvent() { RequestUri = Request.GetAbsoluteRootUri(), User = user, Admin = request.IsAdministrator is true });
var currentUser = await _userManager.GetUserAsync(User);
var userEvent = new UserRegisteredEvent
{
RequestUri = Request.GetAbsoluteRootUri(),
Admin = isNewAdmin,
User = user
};
if (currentUser is not null)
{
userEvent.Kind = UserRegisteredEventKind.Invite;
userEvent.InvitedByUser = currentUser;
};
_eventAggregator.Publish(userEvent);
var model = await FromModel(user);
return CreatedAtAction(string.Empty, model);
}

@ -147,7 +147,7 @@ namespace BTCPayServer.Controllers
return await Login(returnUrl);
}
_logger.LogInformation("User with ID {UserId} logged in with a login code", user!.Id);
_logger.LogInformation("User {Email} logged in with a login code", user!.Email);
await _signInManager.SignInAsync(user, false, "LoginCode");
return RedirectToLocal(returnUrl);
}
@ -215,7 +215,7 @@ namespace BTCPayServer.Controllers
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
if (result.Succeeded)
{
_logger.LogInformation("User {UserId} logged in", user.Id);
_logger.LogInformation("User {Email} logged in", user.Email);
return RedirectToLocal(returnUrl);
}
if (result.RequiresTwoFactor)
@ -230,7 +230,7 @@ namespace BTCPayServer.Controllers
}
if (result.IsLockedOut)
{
_logger.LogWarning("User {UserId} account locked out", user.Id);
_logger.LogWarning("User {Email} tried to log in, but is locked out", user.Email);
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
}
@ -368,7 +368,7 @@ namespace BTCPayServer.Controllers
if (await _fido2Service.CompleteLogin(viewModel.UserId, JObject.Parse(viewModel.Response).ToObject<AuthenticatorAssertionRawResponse>()))
{
await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User logged in");
_logger.LogInformation("User {Email} logged in with FIDO2", user.Email);
return RedirectToLocal(returnUrl);
}
}
@ -455,11 +455,11 @@ namespace BTCPayServer.Controllers
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine);
if (result.Succeeded)
{
_logger.LogInformation("User with ID {UserId} logged in with 2fa", user.Id);
_logger.LogInformation("User {Email} logged in with 2FA", user.Email);
return RedirectToLocal(returnUrl);
}
_logger.LogWarning("Invalid authenticator code entered for user with ID {UserId}", user.Id);
_logger.LogWarning("User {Email} entered invalid authenticator code", user.Email);
ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
return View("SecondaryLogin", new SecondaryLoginViewModel
{
@ -524,17 +524,17 @@ namespace BTCPayServer.Controllers
var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
if (result.Succeeded)
{
_logger.LogInformation("User with ID {UserId} logged in with a recovery code", user.Id);
_logger.LogInformation("User {Email} logged in with a recovery code", user.Email);
return RedirectToLocal(returnUrl);
}
if (result.IsLockedOut)
{
_logger.LogWarning("User with ID {UserId} account locked out", user.Id);
_logger.LogWarning("User {Email} account locked out", user.Email);
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
}
_logger.LogWarning("Invalid recovery code entered for user with ID {UserId}", user.Id);
_logger.LogWarning("User {Email} entered invalid recovery code", user.Email);
ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
return View();
}
@ -600,7 +600,6 @@ namespace BTCPayServer.Controllers
var settings = await _SettingsRepository.GetSettingAsync<ThemeSettings>() ?? new ThemeSettings();
settings.FirstRun = false;
await _SettingsRepository.UpdateSetting(settings);
await _SettingsRepository.FirstAdminRegistered(policies, _Options.UpdateUrl != null, _Options.DisableRegistration, Logs);
RegisteredAdmin = true;
}
@ -614,15 +613,17 @@ namespace BTCPayServer.Controllers
RegisteredUserId = user.Id;
TempData[WellKnownTempData.SuccessMessage] = "Account created.";
if (policies.RequiresConfirmedEmail)
var requiresConfirmedEmail = policies.RequiresConfirmedEmail && !user.EmailConfirmed;
var requiresUserApproval = policies.RequiresUserApproval && !user.Approved;
if (requiresConfirmedEmail)
{
TempData[WellKnownTempData.SuccessMessage] += " Please confirm your email.";
}
if (policies.RequiresUserApproval)
if (requiresUserApproval)
{
TempData[WellKnownTempData.SuccessMessage] += " The new account requires approval by an admin before you can log in.";
}
if (policies.RequiresConfirmedEmail || policies.RequiresUserApproval)
if (requiresConfirmedEmail || requiresUserApproval)
{
return RedirectToAction(nameof(Login));
}
@ -649,9 +650,11 @@ namespace BTCPayServer.Controllers
[HttpGet("/logout")]
public async Task<IActionResult> Logout()
{
var userId = _signInManager.UserManager.GetUserId(HttpContext.User);
var user = await _userManager.FindByIdAsync(userId);
await _signInManager.SignOutAsync();
HttpContext.DeleteUserPrefsCookie();
_logger.LogInformation("User logged out");
_logger.LogInformation("User {Email} logged out", user!.Email);
return RedirectToAction(nameof(Login));
}
@ -670,25 +673,31 @@ namespace BTCPayServer.Controllers
}
var result = await _userManager.ConfirmEmailAsync(user, code);
if (!await _userManager.HasPasswordAsync(user))
if (result.Succeeded)
{
_eventAggregator.Publish(new UserConfirmedEmailEvent
{
User = user,
RequestUri = Request.GetAbsoluteRootUri()
});
var hasPassword = await _userManager.HasPasswordAsync(user);
if (hasPassword)
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Your email has been confirmed."
});
return RedirectToAction(nameof(Login), new { email = user.Email });
}
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Info,
Message = "Your email has been confirmed but you still need to set your password."
Message = "Your email has been confirmed. Please set your password."
});
return RedirectToAction("SetPassword", new { email = user.Email, code = await _userManager.GeneratePasswordResetTokenAsync(user) });
}
if (result.Succeeded)
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Your email has been confirmed."
});
return RedirectToAction("Login", new { email = user.Email });
return await RedirectToSetPassword(user);
}
return View("Error");
@ -740,17 +749,23 @@ namespace BTCPayServer.Controllers
{
if (code == null)
{
throw new ApplicationException("A code must be supplied for password reset.");
throw new ApplicationException("A code must be supplied for this action.");
}
var user = string.IsNullOrEmpty(userId) ? null : await _userManager.FindByIdAsync(userId);
var hasPassword = user != null && await _userManager.HasPasswordAsync(user);
if (!string.IsNullOrEmpty(userId))
{
var user = await _userManager.FindByIdAsync(userId);
email = user?.Email;
}
var model = new SetPasswordViewModel { Code = code, Email = email, EmailSetInternally = !string.IsNullOrEmpty(email) };
return View(model);
return View(new SetPasswordViewModel
{
Code = code,
Email = email,
EmailSetInternally = !string.IsNullOrEmpty(email),
HasPassword = hasPassword
});
}
[HttpPost("/login/set-password")]
@ -762,7 +777,9 @@ namespace BTCPayServer.Controllers
{
return View(model);
}
var user = await _userManager.FindByEmailAsync(model.Email);
var hasPassword = user != null && await _userManager.HasPasswordAsync(user);
if (!UserService.TryCanLogin(user, out _))
{
// Don't reveal that the user does not exist
@ -775,15 +792,76 @@ namespace BTCPayServer.Controllers
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Password successfully set."
Message = hasPassword ? "Password successfully set." : "Account successfully created."
});
return RedirectToAction(nameof(Login));
}
AddErrors(result);
model.HasPassword = await _userManager.HasPasswordAsync(user);
return View(model);
}
[AllowAnonymous]
[HttpGet("/invite/{userId}/{code}")]
public async Task<IActionResult> AcceptInvite(string userId, string code)
{
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(code))
{
return NotFound();
}
var user = await _userManager.FindByInvitationTokenAsync(userId, Uri.UnescapeDataString(code));
if (user == null)
{
return NotFound();
}
var requiresEmailConfirmation = user.RequiresEmailConfirmation && !user.EmailConfirmed;
var requiresSetPassword = !await _userManager.HasPasswordAsync(user);
_eventAggregator.Publish(new UserInviteAcceptedEvent
{
User = user,
RequestUri = Request.GetAbsoluteRootUri()
});
if (requiresEmailConfirmation)
{
return await RedirectToConfirmEmail(user);
}
if (requiresSetPassword)
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Info,
Message = "Invitation accepted. Please set your password."
});
return await RedirectToSetPassword(user);
}
// Inform user that a password has been set on account creation
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Info,
Message = "Your password has been set by the user who invited you."
});
return RedirectToAction(nameof(Login), new { email = user.Email });
}
private async Task<IActionResult> RedirectToConfirmEmail(ApplicationUser user)
{
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
return RedirectToAction(nameof(ConfirmEmail), new { userId = user.Id, code });
}
private async Task<IActionResult> RedirectToSetPassword(ApplicationUser user)
{
var code = await _userManager.GeneratePasswordResetTokenAsync(user);
return RedirectToAction(nameof(SetPassword), new { userId = user.Id, email = user.Email, code });
}
#region Helpers
private void AddErrors(IdentityResult result)

@ -14,20 +14,11 @@ using System.Threading;
using System;
using NBitcoin.DataEncoders;
using System.Text.Json.Serialization;
using BTCPayServer.HostedServices;
using BTCPayServer.Services.Stores;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
using System.Reflection.Metadata;
namespace BTCPayServer.Controllers;
public class UIBoltcardController : Controller
{
private readonly PullPaymentHostedService _ppService;
private readonly StoreRepository _storeRepository;
public class BoltcardSettings
{
[JsonConverter(typeof(NBitcoin.JsonConverters.HexJsonConverter))]
@ -37,15 +28,11 @@ public class UIBoltcardController : Controller
UILNURLController lnUrlController,
SettingsRepository settingsRepository,
ApplicationDbContextFactory contextFactory,
PullPaymentHostedService ppService,
StoreRepository storeRepository,
BTCPayServerEnvironment env)
{
LNURLController = lnUrlController;
SettingsRepository = settingsRepository;
ContextFactory = contextFactory;
_ppService = ppService;
_storeRepository = storeRepository;
Env = env;
}
@ -54,64 +41,6 @@ public class UIBoltcardController : Controller
public ApplicationDbContextFactory ContextFactory { get; }
public BTCPayServerEnvironment Env { get; }
[AllowAnonymous]
[HttpGet("~/boltcard/pay")]
public async Task<IActionResult> GetPayRequest([FromQuery] string? p, [FromQuery] long? amount = null)
{
var issuerKey = await SettingsRepository.GetIssuerKey(Env);
var piccData = issuerKey.TryDecrypt(p);
if (piccData is null)
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Invalid PICCData" });
piccData = new BoltcardPICCData(piccData.Uid, int.MaxValue - 10); // do not check the counter
var registration = await ContextFactory.GetBoltcardRegistration(issuerKey, piccData, false);
var pp = await _ppService.GetPullPayment(registration!.PullPaymentId, false);
var store = await _storeRepository.FindStore(pp.StoreId);
var lnUrlMetadata = new Dictionary<string, string>();
lnUrlMetadata.Add("text/plain", "Boltcard Top-Up");
var payRequest = new LNURLPayRequest
{
Tag = "payRequest",
MinSendable = LightMoney.Satoshis(1.0m),
MaxSendable = LightMoney.FromUnit(6.12m, LightMoneyUnit.BTC),
Callback = new Uri(GetPayLink(p, Request.Scheme), UriKind.Absolute),
CommentAllowed = 0
};
payRequest.Metadata = Newtonsoft.Json.JsonConvert.SerializeObject(lnUrlMetadata.Select(kv => new[] { kv.Key, kv.Value }));
if (amount is null)
return Ok(payRequest);
var cryptoCode = "BTC";
var currency = "BTC";
var invoiceAmount = LightMoney.FromUnit(amount.Value, LightMoneyUnit.MilliSatoshi).ToUnit(LightMoneyUnit.BTC);
if (pp.GetBlob().Currency == "SATS")
{
currency = "SATS";
invoiceAmount = LightMoney.FromUnit(amount.Value, LightMoneyUnit.MilliSatoshi).ToUnit(LightMoneyUnit.Satoshi);
}
LNURLController.ControllerContext.HttpContext = HttpContext;
var result = await LNURLController.GetLNURLRequest(
cryptoCode,
store,
store.GetStoreBlob(),
new CreateInvoiceRequest()
{
Currency = currency,
Amount = invoiceAmount
},
payRequest,
lnUrlMetadata,
[PullPaymentHostedService.GetInternalTag(pp.Id)]);
if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest2)
return result;
payRequest = payRequest2;
var invoiceId = payRequest.Callback.AbsoluteUri.Split('/').Last();
return await LNURLController.GetLNURLForInvoice(invoiceId, cryptoCode, amount.Value, null);
}
[AllowAnonymous]
[HttpGet("~/boltcard")]
public async Task<IActionResult> GetWithdrawRequest([FromQuery] string? p, [FromQuery] string? c, [FromQuery] string? pr, [FromQuery] string? k1, CancellationToken cancellationToken)
@ -136,16 +65,6 @@ public class UIBoltcardController : Controller
if (!cardKey.CheckSunMac(c, piccData))
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Replayed or expired query" });
LNURLController.ControllerContext.HttpContext = HttpContext;
var res = await LNURLController.GetLNURLForPullPayment("BTC", registration.PullPaymentId, pr, $"{p}-{c}", cancellationToken);
if (res is not OkObjectResult ok || ok.Value is not LNURLWithdrawRequest withdrawRequest)
return res;
var paylink = GetPayLink(p, "lnurlp");
withdrawRequest.PayLink = new Uri(paylink, UriKind.Absolute);
return res;
}
private string GetPayLink(string? p, string scheme)
{
return Url.Action(nameof(GetPayRequest), "UIBoltcard", new { p }, scheme)!;
return await LNURLController.GetLNURLForPullPayment("BTC", registration.PullPaymentId, pr, $"{p}-{c}", cancellationToken);
}
}

@ -29,7 +29,6 @@ namespace BTCPayServer.Controllers
{
private readonly ThemeSettings _theme;
private readonly StoreRepository _storeRepository;
private readonly BTCPayNetworkProvider _networkProvider;
private IHttpClientFactory HttpClientFactory { get; }
private SignInManager<ApplicationUser> SignInManager { get; }
@ -41,14 +40,12 @@ namespace BTCPayServer.Controllers
ThemeSettings theme,
LanguageService languageService,
StoreRepository storeRepository,
BTCPayNetworkProvider networkProvider,
IWebHostEnvironment environment,
SignInManager<ApplicationUser> signInManager)
{
_theme = theme;
HttpClientFactory = httpClientFactory;
LanguageService = languageService;
_networkProvider = networkProvider;
_storeRepository = storeRepository;
SignInManager = signInManager;
_WebRootFileProvider = environment.WebRootFileProvider;
@ -76,17 +73,17 @@ namespace BTCPayServer.Controllers
if (storeId != null)
{
// verify store exists and redirect to it
var store = await _storeRepository.FindStore(storeId, userId);
var store = await _storeRepository.FindStore(storeId);
if (store != null)
{
return RedirectToStore(userId, store);
return RedirectToAction(nameof(UIStoresController.Index), "UIStores", new { storeId });
}
}
var stores = await _storeRepository.GetStoresByUserId(userId);
var stores = await _storeRepository.GetStoresByUserId(userId!);
var activeStore = stores.FirstOrDefault(s => !s.Archived);
return activeStore != null
? RedirectToStore(userId, activeStore)
? RedirectToAction(nameof(UIStoresController.Index), "UIStores", new { storeId = activeStore.Id })
: RedirectToAction(nameof(UIUserStoresController.CreateStore), "UIUserStores");
}
@ -198,14 +195,5 @@ namespace BTCPayServer.Controllers
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
public static RedirectToActionResult RedirectToStore(string userId, StoreData store)
{
var perms = store.GetPermissionSet(userId);
if (perms.Contains(Policies.CanModifyStoreSettings, store.Id))
return new RedirectToActionResult("Dashboard", "UIStores", new {storeId = store.Id});
if (perms.Contains(Policies.CanViewInvoices, store.Id))
return new RedirectToActionResult("ListInvoices", "UIInvoice", new { storeId = store.Id });
return new RedirectToActionResult("Index", "UIStores", new {storeId = store.Id});
}
}
}

@ -162,10 +162,10 @@ namespace BTCPayServer.Controllers
model.Overpaid = details.Overpaid;
model.StillDue = details.StillDue;
model.HasRates = details.HasRates;
if (additionalData.ContainsKey("receiptData"))
if (additionalData.TryGetValue("receiptData", out object? receiptData))
{
model.ReceiptData = (Dictionary<string, object>)additionalData["receiptData"];
model.ReceiptData = (Dictionary<string, object>)receiptData;
additionalData.Remove("receiptData");
}
@ -213,7 +213,7 @@ namespace BTCPayServer.Controllers
{
InvoiceId = i.Id,
OrderId = i.Metadata?.OrderId,
OrderUrl = i.Metadata?.OrderUrl,
RedirectUrl = i.RedirectURL?.AbsoluteUri ?? i.Metadata?.OrderUrl,
Status = i.Status.ToModernStatus(),
Currency = i.Currency,
Timestamp = i.InvoiceTime,
@ -226,15 +226,42 @@ namespace BTCPayServer.Controllers
{
return View(vm);
}
JToken? receiptData = null;
i.Metadata?.AdditionalData?.TryGetValue("receiptData", out receiptData);
var metaData = PosDataParser.ParsePosData(i.Metadata?.ToJObject());
var additionalData = metaData
.Where(dict => !InvoiceAdditionalDataExclude.Contains(dict.Key))
.ToDictionary(dict => dict.Key, dict => dict.Value);
// Split receipt data into cart and additional data
if (additionalData.TryGetValue("receiptData", out object? combinedReceiptData))
{
var receiptData = new Dictionary<string, object>((Dictionary<string, object>)combinedReceiptData, StringComparer.OrdinalIgnoreCase);
string[] cartKeys = ["cart", "subtotal", "discount", "tip", "total"];
// extract cart data and lowercase keys to handle data uniformly in PosData partial
if (receiptData.Keys.Any(key => cartKeys.Contains(key.ToLowerInvariant())))
{
vm.CartData = new Dictionary<string, object>();
foreach (var key in cartKeys)
{
if (!receiptData.ContainsKey(key)) continue;
// add it to cart data and remove it from the general data
vm.CartData.Add(key.ToLowerInvariant(), receiptData[key]);
receiptData.Remove(key);
}
}
// assign the rest to additional data and remove empty values
if (receiptData.Any())
{
vm.AdditionalData = receiptData
.Where(x => !string.IsNullOrEmpty(x.Value.ToString()))
.ToDictionary(x => x.Key, x => x.Value);
}
}
var payments = ViewPaymentRequestViewModel.PaymentRequestInvoicePayment.GetViewModels(i, _displayFormatter, _transactionLinkProviders);
vm.Amount = i.PaidAmount.Net;
vm.Payments = receipt.ShowPayments is false ? null : payments;
vm.AdditionalData = PosDataParser.ParsePosData(receiptData);
return View(print ? "InvoiceReceiptPrint" : "InvoiceReceipt", vm);
}
@ -341,6 +368,11 @@ namespace BTCPayServer.Controllers
accounting = paymentMethod.Calculate();
cryptoPaid = accounting.Paid;
dueAmount = accounting.TotalDue;
if (cryptoPaid is 0 && invoice is { Status: InvoiceStatusLegacy.Confirmed or InvoiceStatusLegacy.Complete, ExceptionStatus: InvoiceExceptionStatus.Marked })
{
cryptoPaid = accounting.TotalDue;
dueAmount = 0;
}
paidAmount = cryptoPaid.RoundToSignificant(appliedDivisibility);
}
@ -612,7 +644,7 @@ namespace BTCPayServer.Controllers
await _InvoiceRepository.MassArchive(selectedItems, false);
TempData[WellKnownTempData.SuccessMessage] = $"{selectedItems.Length} invoice{(selectedItems.Length == 1 ? "" : "s")} unarchived.";
break;
case "cpfp":
case "cpfp" when storeId is not null:
var network = _NetworkProvider.DefaultNetwork;
var explorer = _ExplorerClients.GetExplorerClient(network);
if (explorer is null)
@ -1073,7 +1105,7 @@ namespace BTCPayServer.Controllers
storeIds.Add(i);
}
model.Search = fs;
model.SearchText = fs.TextSearch;
model.SearchText = fs.TextCombined;
var apps = await _appService.GetAllApps(GetUserId(), false, storeId);
InvoiceQuery invoiceQuery = GetInvoiceQuery(fs, apps, timezoneOffset);
@ -1144,7 +1176,7 @@ namespace BTCPayServer.Controllers
[HttpGet("/stores/{storeId}/invoices/create")]
[HttpGet("invoices/create")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanCreateInvoice, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[BitpayAPIConstraint(false)]
public async Task<IActionResult> CreateInvoice(InvoicesModel? model = null)
{
@ -1154,7 +1186,7 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
}
var store = await _StoreRepository.FindStore(model.StoreId, GetUserId());
var store = await _StoreRepository.FindStore(model.StoreId);
if (store == null)
return NotFound();

@ -26,7 +26,6 @@ using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.PaymentRequests;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using LNURL;
@ -91,11 +90,14 @@ namespace BTCPayServer
_pluginHookService = pluginHookService;
_invoiceActivator = invoiceActivator;
}
[EnableCors(CorsPolicies.All)]
[HttpGet("withdraw/pp/{pullPaymentId}")]
public Task<IActionResult> GetLNURLForPullPayment(string cryptoCode, string pullPaymentId, [FromQuery] string pr, CancellationToken cancellationToken)
{
return GetLNURLForPullPayment(cryptoCode, pullPaymentId, pr, pullPaymentId, cancellationToken);
}
[NonAction]
internal async Task<IActionResult> GetLNURLForPullPayment(string cryptoCode, string pullPaymentId, string pr, string k1, CancellationToken cancellationToken)
{
@ -297,11 +299,11 @@ namespace BTCPayServer
return NotFound();
}
var createInvoice = new CreateInvoiceRequest()
var createInvoice = new CreateInvoiceRequest
{
Amount = item?.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup? null: item?.Price,
Amount = item?.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? null : item?.Price,
Currency = currencyCode,
Checkout = new InvoiceDataBase.CheckoutOptions()
Checkout = new InvoiceDataBase.CheckoutOptions
{
RedirectURL = app.AppType switch
{
@ -313,6 +315,7 @@ namespace BTCPayServer
AdditionalSearchTerms = new[] { AppService.GetAppSearchTerm(app) }
};
var allowOverpay = item?.PriceType is not ViewPointOfSaleViewModel.ItemPriceType.Fixed;
var invoiceMetadata = new InvoiceMetadata { OrderId = AppService.GetRandomOrderId() };
if (item != null)
{
@ -327,7 +330,7 @@ namespace BTCPayServer
store.GetStoreBlob(),
createInvoice,
additionalTags: new List<string> { AppService.GetAppInternalTag(appId) },
allowOverpay: false);
allowOverpay: allowOverpay);
}
public class EditLightningAddressVM
@ -437,13 +440,6 @@ namespace BTCPayServer
var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId);
if (store is null)
return NotFound("Unknown username");
List<string> additionalTags = new List<string>();
if (blob?.PullPaymentId is not null)
{
var pp = await _pullPaymentHostedService.GetPullPayment(blob.PullPaymentId, false);
if (pp != null)
additionalTags.Add(PullPaymentHostedService.GetInternalTag(blob.PullPaymentId));
}
var result = await GetLNURLRequest(
cryptoCode,
store,
@ -461,7 +457,7 @@ namespace BTCPayServer
new Dictionary<string, string>
{
{ "text/identifier", $"{username}@{Request.Host}" }
}, additionalTags);
});
if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest)
return result;
var invoiceId = payRequest.Callback.AbsoluteUri.Split('/').Last();
@ -503,7 +499,7 @@ namespace BTCPayServer
});
}
internal async Task<IActionResult> GetLNURLRequest(
public async Task<IActionResult> GetLNURLRequest(
string cryptoCode,
Data.StoreData store,
Data.StoreBlob blob,
@ -530,7 +526,9 @@ namespace BTCPayServer
return this.CreateAPIError(null, e.Message);
}
lnurlRequest = await CreateLNUrlRequestFromInvoice(cryptoCode, i, store, blob, lnurlRequest, lnUrlMetadata, allowOverpay);
return lnurlRequest is null ? NotFound() : Ok(lnurlRequest);
return lnurlRequest is null
? BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Unable to create LNURL request." })
: Ok(lnurlRequest);
}
private async Task<LNURLPayRequest> CreateLNUrlRequestFromInvoice(

@ -50,7 +50,7 @@ namespace BTCPayServer.Controllers
$"Unexpected error occurred disabling 2FA for user with ID '{user.Id}'.");
}
_logger.LogInformation("User with ID {UserId} has disabled 2fa.", user.Id);
_logger.LogInformation("User {Email} has disabled 2fa", user.Email);
return RedirectToAction(nameof(TwoFactorAuthentication));
}
@ -100,7 +100,7 @@ namespace BTCPayServer.Controllers
}
await _userManager.SetTwoFactorEnabledAsync(user, true);
_logger.LogInformation("User with ID {UserId} has enabled 2FA with an authenticator app.", user.Id);
_logger.LogInformation("User {Email} has enabled 2FA with an authenticator app", user.Email);
var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
TempData[RecoveryCodesKey] = recoveryCodes.ToArray();
@ -117,7 +117,7 @@ namespace BTCPayServer.Controllers
await _userManager.SetTwoFactorEnabledAsync(user, false);
await _userManager.ResetAuthenticatorKeyAsync(user);
_logger.LogInformation("User with id '{UserId}' has reset their authentication app key.", user.Id);
_logger.LogInformation("User {Email} has reset their authentication app key", user.Email);
return RedirectToAction(nameof(EnableAuthenticator));
}

@ -26,7 +26,7 @@ namespace BTCPayServer.Controllers
new List<string>();
var notifications = notificationHandlers.SelectMany(handler => handler.Meta.Select(tuple =>
new SelectListItem(tuple.name, tuple.identifier,
disabledNotifications.Contains(tuple.identifier, StringComparer.InvariantCultureIgnoreCase))))
!disabledNotifications.Contains(tuple.identifier, StringComparer.InvariantCultureIgnoreCase))))
.ToList();
return View(new NotificationSettingsViewModel { DisabledNotifications = notifications });
@ -46,7 +46,7 @@ namespace BTCPayServer.Controllers
}
else if (command == "update")
{
var disabled = vm.DisabledNotifications.Where(item => item.Selected).Select(item => item.Value)
var disabled = vm.DisabledNotifications.Where(item => !item.Selected).Select(item => item.Value)
.ToArray();
user.DisabledNotifications = disabled.Any()
? string.Join(';', disabled) + ";"

@ -108,7 +108,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("/stores/{storeId}/payment-requests/edit/{payReqId?}")]
[Authorize(Policy = Policies.CanViewPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanModifyPaymentRequests, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> EditPaymentRequest(string storeId, string payReqId)
{
var store = GetCurrentStore();
@ -277,6 +277,10 @@ namespace BTCPayServer.Controllers
if (FormDataService.Validate(form, ModelState))
{
prBlob.FormResponse = FormDataService.GetValues(form);
if(string.IsNullOrEmpty(prBlob.Email) && form.GetFieldByFullName("buyerEmail") is { } emailField)
{
prBlob.Email = emailField.Value;
}
result.SetBlob(prBlob);
await _PaymentRequestRepository.CreateOrUpdatePaymentRequest(result);
return RedirectToAction("PayPaymentRequest", new { payReqId });

@ -227,6 +227,7 @@ namespace BTCPayServer.Controllers
var supported = ppBlob.SupportedPaymentMethods;
PaymentMethodId paymentMethodId = null;
IClaimDestination destination = null;
string error = null;
if (string.IsNullOrEmpty(vm.SelectedPaymentMethod))
{
foreach (var pmId in supported)
@ -235,6 +236,7 @@ namespace BTCPayServer.Controllers
(IClaimDestination dst, string err) = handler == null
? (null, "No payment handler found for this payment method")
: await handler.ParseAndValidateClaimDestination(pmId, vm.Destination, ppBlob, cancellationToken);
error = err;
if (dst is not null && err is null)
{
paymentMethodId = pmId;
@ -247,12 +249,15 @@ namespace BTCPayServer.Controllers
{
paymentMethodId = supported.FirstOrDefault(id => vm.SelectedPaymentMethod == id.ToString());
var payoutHandler = paymentMethodId is null ? null : _payoutHandlers.FindPayoutHandler(paymentMethodId);
destination = payoutHandler is null ? null : (await payoutHandler.ParseAndValidateClaimDestination(paymentMethodId, vm.Destination, ppBlob, cancellationToken)).destination;
if (payoutHandler is not null)
{
(destination, error) = await payoutHandler.ParseAndValidateClaimDestination(paymentMethodId, vm.Destination, ppBlob, cancellationToken);
}
}
if (destination is null)
{
ModelState.AddModelError(nameof(vm.Destination), "Invalid destination or payment method");
ModelState.AddModelError(nameof(vm.Destination), error ?? "Invalid destination or payment method");
return await ViewPullPayment(pullPaymentId);
}

@ -64,7 +64,27 @@ public partial class UIReportsController
decimal randomValue = ((decimal)rand.NextDouble() * range) + from;
return decimal.Round(randomValue, precision);
}
JObject GetFormattedAmount()
{
string? curr = null;
decimal value = 0m;
int offset = 0;
while (curr is null)
{
curr = row[fi - 1 - offset]?.ToString();
value = curr switch
{
"USD" or "EUR" or "CHF" => GenerateDecimal(30_000m, 60_000, 2),
"JPY" => GenerateDecimal(400_0000m, 1000_0000m, 0),
_ => 0.0m
};
if (value != 0.0m)
break;
curr = null;
offset++;
}
return DisplayFormatter.ToFormattedAmount(value, curr);
}
var fiatCurrency = rand.NextSingle() > 0.2 ? "USD" : TakeOne("JPY", "EUR", "CHF");
var cryptoCurrency = rand.NextSingle() > 0.2 ? "BTC" : TakeOne("LTC", "DOGE", "DASH");
@ -116,14 +136,11 @@ public partial class UIReportsController
return Encoders.Hex.EncodeData(GenerateBytes(32));
if (f.Name == "Rate")
{
var curr = row[fi - 1]?.ToString();
var value = curr 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 DisplayFormatter.ToFormattedAmount(value, curr);
return GetFormattedAmount();
}
if (f.Type == "amount")
{
return GetFormattedAmount();
}
return null;
}

@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
@ -16,29 +15,25 @@ namespace BTCPayServer.Controllers
{
[Route("server/roles")]
public async Task<IActionResult> ListRoles(
[FromServices] StoreRepository storeRepository,
RolesViewModel model,
string sortOrder = null
)
{
var roles = await _StoreRepository.GetStoreRoles(null, true);
var defaultRole = (await _StoreRepository.GetDefaultRole()).Role;
model ??= new RolesViewModel();
model.DefaultRole = defaultRole;
model.DefaultRole = (await storeRepository.GetDefaultRole()).Role;
var roles = await storeRepository.GetStoreRoles(null);
if (sortOrder != null)
switch (sortOrder)
{
switch (sortOrder)
{
case "desc":
ViewData["NextRoleSortOrder"] = "asc";
roles = roles.OrderByDescending(user => user.Role).ToArray();
break;
case "asc":
roles = roles.OrderBy(user => user.Role).ToArray();
ViewData["NextRoleSortOrder"] = "desc";
break;
}
case "desc":
ViewData["NextRoleSortOrder"] = "asc";
roles = roles.OrderByDescending(user => user.Role).ToArray();
break;
case "asc":
roles = roles.OrderBy(user => user.Role).ToArray();
ViewData["NextRoleSortOrder"] = "desc";
break;
}
model.Roles = roles.Skip(model.Skip).Take(model.Count).ToList();
@ -47,32 +42,26 @@ namespace BTCPayServer.Controllers
}
[HttpGet("server/roles/{role}")]
public async Task<IActionResult> CreateOrEditRole(
[FromServices] StoreRepository storeRepository,
string role)
public async Task<IActionResult> CreateOrEditRole(string role)
{
if (role == "create")
{
ModelState.Remove(nameof(role));
return View(new UpdateRoleViewModel());
}
else
var roleData = await _StoreRepository.GetStoreRole(new StoreRoleId(role));
if (roleData == null)
return NotFound();
return View(new UpdateRoleViewModel
{
var roleData = await storeRepository.GetStoreRole(new StoreRoleId(role));
if (roleData == null)
return NotFound();
return View(new UpdateRoleViewModel()
{
Policies = roleData.Permissions,
Role = roleData.Role
});
}
Policies = roleData.Permissions,
Role = roleData.Role
});
}
[HttpPost("server/roles/{role}")]
public async Task<IActionResult> CreateOrEditRole(
[FromServices] StoreRepository storeRepository,
[FromRoute] string role, UpdateRoleViewModel viewModel)
public async Task<IActionResult> CreateOrEditRole([FromRoute] string role, UpdateRoleViewModel viewModel)
{
string successMessage = null;
if (role == "create")
@ -83,7 +72,7 @@ namespace BTCPayServer.Controllers
else
{
successMessage = "Role updated";
var storeRole = await storeRepository.GetStoreRole(new StoreRoleId(role));
var storeRole = await _StoreRepository.GetStoreRole(new StoreRoleId(role));
if (storeRole == null)
return NotFound();
}
@ -93,7 +82,7 @@ namespace BTCPayServer.Controllers
return View(viewModel);
}
var r = await storeRepository.AddOrUpdateStoreRole(new StoreRoleId(role), viewModel.Policies);
var r = await _StoreRepository.AddOrUpdateStoreRole(new StoreRoleId(role), viewModel.Policies);
if (r is null)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
@ -116,11 +105,9 @@ namespace BTCPayServer.Controllers
[HttpGet("server/roles/{role}/delete")]
public async Task<IActionResult> DeleteRole(
[FromServices] StoreRepository storeRepository,
string role)
public async Task<IActionResult> DeleteRole(string role)
{
var roleData = await storeRepository.GetStoreRole(new StoreRoleId(role), true);
var roleData = await _StoreRepository.GetStoreRole(new StoreRoleId(role), true);
if (roleData == null)
return NotFound();
@ -134,12 +121,10 @@ namespace BTCPayServer.Controllers
}
[HttpPost("server/roles/{role}/delete")]
public async Task<IActionResult> DeleteRolePost(
[FromServices] StoreRepository storeRepository,
string role)
public async Task<IActionResult> DeleteRolePost(string role)
{
var roleId = new StoreRoleId(role);
var roleData = await storeRepository.GetStoreRole(roleId, true);
var roleData = await _StoreRepository.GetStoreRole(roleId, true);
if (roleData == null)
return NotFound();
if (roleData.IsUsed is true)
@ -147,7 +132,7 @@ namespace BTCPayServer.Controllers
return BadRequest();
}
var errorMessage = await storeRepository.RemoveStoreRole(roleId);
var errorMessage = await _StoreRepository.RemoveStoreRole(roleId);
if (errorMessage is null)
{
@ -162,19 +147,16 @@ namespace BTCPayServer.Controllers
}
[HttpGet("server/roles/{role}/default")]
public async Task<IActionResult> SetDefaultRole(
[FromServices] StoreRepository storeRepository,
string role)
public async Task<IActionResult> SetDefaultRole(string role)
{
var resolved = await storeRepository.ResolveStoreRoleId(null, role);
var resolved = await _StoreRepository.ResolveStoreRoleId(null, role);
if (resolved is null)
{
TempData[WellKnownTempData.ErrorMessage] = "Role could not be set as default";
}
else
{
await storeRepository.SetDefaultRole(role);
await _StoreRepository.SetDefaultRole(role);
TempData[WellKnownTempData.SuccessMessage] = "Role set default";
}

@ -9,6 +9,7 @@ using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Services;
using BTCPayServer.Services.Mails;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -52,6 +53,8 @@ namespace BTCPayServer.Controllers
model.Roles = roleManager.Roles.ToDictionary(role => role.Id, role => role.Name);
model.Users = await usersQuery
.Include(user => user.UserRoles)
.Include(user => user.UserStores)
.ThenInclude(data => data.StoreData)
.Skip(model.Skip)
.Take(model.Count)
.Select(u => new UsersViewModel.UserViewModel
@ -63,7 +66,8 @@ namespace BTCPayServer.Controllers
Approved = u.RequiresApproval ? u.Approved : null,
Created = u.Created,
Roles = u.UserRoles.Select(role => role.RoleId),
Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime
Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime,
Stores = u.UserStores.OrderBy(s => !s.StoreData.Archived).ToList()
})
.ToListAsync();
@ -99,7 +103,7 @@ namespace BTCPayServer.Controllers
bool? adminStatusChanged = null;
bool? approvalStatusChanged = null;
if (user.RequiresApproval && viewModel.Approved.HasValue)
if (user.RequiresApproval && viewModel.Approved.HasValue && user.Approved != viewModel.Approved.Value)
{
approvalStatusChanged = await _userService.SetUserApproval(user.Id, viewModel.Approved.Value, Request.GetAbsoluteRootUri());
}
@ -146,7 +150,6 @@ namespace BTCPayServer.Controllers
[HttpGet("server/users/new")]
public IActionResult CreateUser()
{
ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval;
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
return View();
}
@ -154,13 +157,11 @@ namespace BTCPayServer.Controllers
[HttpPost("server/users/new")]
public async Task<IActionResult> CreateUser(RegisterFromAdminViewModel model)
{
ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval;
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
if (!_Options.CheatMode)
model.IsAdmin = false;
if (ModelState.IsValid)
{
IdentityResult result;
var user = new ApplicationUser
{
UserName = model.Email,
@ -168,18 +169,13 @@ namespace BTCPayServer.Controllers
EmailConfirmed = model.EmailConfirmed,
RequiresEmailConfirmation = _policiesSettings.RequiresConfirmedEmail,
RequiresApproval = _policiesSettings.RequiresUserApproval,
Approved = model.Approved,
Approved = true, // auto-approve users created by an admin
Created = DateTimeOffset.UtcNow
};
if (!string.IsNullOrEmpty(model.Password))
{
result = await _UserManager.CreateAsync(user, model.Password);
}
else
{
result = await _UserManager.CreateAsync(user);
}
var result = string.IsNullOrEmpty(model.Password)
? await _UserManager.CreateAsync(user)
: await _UserManager.CreateAsync(user, model.Password);
if (result.Succeeded)
{
@ -187,37 +183,30 @@ namespace BTCPayServer.Controllers
model.IsAdmin = false;
var tcs = new TaskCompletionSource<Uri>();
var currentUser = await _UserManager.GetUserAsync(HttpContext.User);
_eventAggregator.Publish(new UserRegisteredEvent()
_eventAggregator.Publish(new UserRegisteredEvent
{
RequestUri = Request.GetAbsoluteRootUri(),
Kind = UserRegisteredEventKind.Invite,
User = user,
Admin = model.IsAdmin is true,
InvitedByUser = currentUser,
Admin = model.IsAdmin,
CallbackUrlGenerated = tcs
});
var callbackUrl = await tcs.Task;
if (user.RequiresEmailConfirmation && !user.EmailConfirmed)
var settings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
var info = settings.IsComplete()
? "An invitation email has been sent.<br/>You may alternatively"
: "An invitation email has not been sent, because the server does not have an email server configured.<br/> You need to";
TempData.SetStatusMessageModel(new StatusMessageModel
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
AllowDismiss = false,
Html =
$"Account created without a set password. An email will be sent (if configured) to set the password.<br/> You may alternatively share this link with them: <a class='alert-link' href='{callbackUrl}'>{callbackUrl}</a>"
});
}
else if (!await _UserManager.HasPasswordAsync(user))
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Success,
AllowDismiss = false,
Html =
$"Account created without a set password. An email will be sent (if configured) to set the password.<br/> You may alternatively share this link with them: <a class='alert-link' href='{callbackUrl}'>{callbackUrl}</a>"
});
}
Severity = StatusMessageModel.StatusSeverity.Success,
AllowDismiss = false,
Html = $"Account successfully created. {info} share this link with them: <a class='alert-link' href='{callbackUrl}'>{callbackUrl}</a>"
});
return RedirectToAction(nameof(ListUsers));
}
@ -374,8 +363,5 @@ namespace BTCPayServer.Controllers
[Display(Name = "Email confirmed?")]
public bool EmailConfirmed { get; set; }
[Display(Name = "User approved?")]
public bool Approved { get; set; }
}
}

@ -16,19 +16,16 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Hosting;
using BTCPayServer.Logging;
using BTCPayServer.Models;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Stores;
using BTCPayServer.Storage.Models;
using BTCPayServer.Storage.Services;
using BTCPayServer.Storage.Services.Providers;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Identity;
@ -122,21 +119,44 @@ namespace BTCPayServer.Controllers
_transactionLinkProviders = transactionLinkProviders;
}
[Route("server/maintenance")]
public IActionResult Maintenance()
[HttpGet("server/stores")]
public async Task<IActionResult> ListStores()
{
MaintenanceViewModel vm = new MaintenanceViewModel();
vm.CanUseSSH = _sshState.CanUseSSH;
if (!vm.CanUseSSH)
TempData[WellKnownTempData.ErrorMessage] = "Maintenance feature requires access to SSH properly configured in BTCPay Server configuration.";
vm.DNSDomain = this.Request.Host.Host;
if (IPAddress.TryParse(vm.DNSDomain, out var unused))
vm.DNSDomain = null;
var stores = await _StoreRepository.GetStores();
var vm = new ListStoresViewModel
{
Stores = stores
.Select(s => new ListStoresViewModel.StoreViewModel
{
StoreId = s.Id,
StoreName = s.StoreName,
Archived = s.Archived,
Users = s.UserStores
})
.OrderBy(s => !s.Archived)
.ToList()
};
return View(vm);
}
[Route("server/maintenance")]
[HttpPost]
[HttpGet("server/maintenance")]
public IActionResult Maintenance()
{
var vm = new MaintenanceViewModel
{
CanUseSSH = _sshState.CanUseSSH,
DNSDomain = Request.Host.Host
};
if (!vm.CanUseSSH)
TempData[WellKnownTempData.ErrorMessage] = "Maintenance feature requires access to SSH properly configured in BTCPay Server configuration.";
if (IPAddress.TryParse(vm.DNSDomain, out var unused))
vm.DNSDomain = null;
return View(vm);
}
[HttpPost("server/maintenance")]
public async Task<IActionResult> Maintenance(MaintenanceViewModel vm, string command)
{
vm.CanUseSSH = _sshState.CanUseSSH;
@ -147,6 +167,7 @@ namespace BTCPayServer.Controllers
}
if (!ModelState.IsValid)
return View(vm);
if (command == "changedomain")
{
if (string.IsNullOrWhiteSpace(vm.DNSDomain))
@ -302,8 +323,8 @@ namespace BTCPayServer.Controllers
[Route("server/policies")]
public async Task<IActionResult> Policies()
{
ViewBag.AppsList = await GetAppSelectList();
ViewBag.UpdateUrlPresent = _Options.UpdateUrl != null;
ViewBag.AppsList = await GetAppSelectList();
return View(_policiesSettings);
}
@ -992,155 +1013,193 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(Services));
}
[HttpGet("server/theme")]
public async Task<IActionResult> Theme()
[HttpGet("server/branding")]
public async Task<IActionResult> Branding()
{
var data = await _SettingsRepository.GetSettingAsync<ThemeSettings>() ?? new ThemeSettings();
return View(data);
var server = await _SettingsRepository.GetSettingAsync<ServerSettings>() ?? new ServerSettings();
var theme = await _SettingsRepository.GetSettingAsync<ThemeSettings>() ?? new ThemeSettings();
var vm = new BrandingViewModel
{
ServerName = server.ServerName,
ContactUrl = server.ContactUrl,
CustomTheme = theme.CustomTheme,
CustomThemeExtension = theme.CustomThemeExtension,
CustomThemeCssUri = theme.CustomThemeCssUri,
CustomThemeFileId = theme.CustomThemeFileId,
LogoFileId = theme.LogoFileId
};
return View(vm);
}
[HttpPost("server/theme")]
public async Task<IActionResult> Theme(
ThemeSettings model,
[HttpPost("server/branding")]
public async Task<IActionResult> Branding(
BrandingViewModel vm,
[FromForm] bool RemoveLogoFile,
[FromForm] bool RemoveCustomThemeFile)
{
var settingsChanged = false;
var settings = await _SettingsRepository.GetSettingAsync<ThemeSettings>() ?? new ThemeSettings();
var server = await _SettingsRepository.GetSettingAsync<ServerSettings>() ?? new ServerSettings();
var theme = await _SettingsRepository.GetSettingAsync<ThemeSettings>() ?? new ThemeSettings();
var userId = GetUserId();
if (userId is null)
return NotFound();
if (model.CustomThemeFile != null)
vm.LogoFileId = theme.LogoFileId;
vm.CustomThemeFileId = theme.CustomThemeFileId;
if (server.ServerName != vm.ServerName)
{
if (model.CustomThemeFile.ContentType.Equals("text/css", StringComparison.InvariantCulture))
server.ServerName = vm.ServerName;
settingsChanged = true;
}
if (server.ContactUrl != vm.ContactUrl)
{
server.ContactUrl = !string.IsNullOrWhiteSpace(vm.ContactUrl)
? vm.ContactUrl.IsValidEmail() ? $"mailto:{vm.ContactUrl}" : vm.ContactUrl
: null;
settingsChanged = true;
}
if (settingsChanged)
{
await _SettingsRepository.UpdateSetting(server);
}
if (vm.CustomThemeFile != null)
{
if (vm.CustomThemeFile.ContentType.Equals("text/css", StringComparison.InvariantCulture))
{
// delete existing file
if (!string.IsNullOrEmpty(settings.CustomThemeFileId))
if (!string.IsNullOrEmpty(theme.CustomThemeFileId))
{
await _fileService.RemoveFile(settings.CustomThemeFileId, userId);
await _fileService.RemoveFile(theme.CustomThemeFileId, userId);
}
// add new file
try
{
var storedFile = await _fileService.AddFile(model.CustomThemeFile, userId);
settings.CustomThemeFileId = storedFile.Id;
var storedFile = await _fileService.AddFile(vm.CustomThemeFile, userId);
vm.CustomThemeFileId = theme.CustomThemeFileId = storedFile.Id;
settingsChanged = true;
}
catch (Exception e)
{
ModelState.AddModelError(nameof(settings.CustomThemeFile), $"Could not save theme file: {e.Message}");
ModelState.AddModelError(nameof(vm.CustomThemeFile), $"Could not save theme file: {e.Message}");
}
}
else
{
ModelState.AddModelError(nameof(settings.CustomThemeFile), "The uploaded theme file needs to be a CSS file");
ModelState.AddModelError(nameof(vm.CustomThemeFile), "The uploaded theme file needs to be a CSS file");
}
}
else if (RemoveCustomThemeFile && !string.IsNullOrEmpty(settings.CustomThemeFileId))
else if (RemoveCustomThemeFile && !string.IsNullOrEmpty(theme.CustomThemeFileId))
{
await _fileService.RemoveFile(settings.CustomThemeFileId, userId);
settings.CustomThemeFileId = null;
await _fileService.RemoveFile(theme.CustomThemeFileId, userId);
vm.CustomThemeFileId = theme.CustomThemeFileId = null;
settingsChanged = true;
}
if (model.LogoFile != null)
if (vm.LogoFile != null)
{
if (model.LogoFile.Length > 1_000_000)
if (vm.LogoFile.Length > 1_000_000)
{
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file should be less than 1MB");
ModelState.AddModelError(nameof(vm.LogoFile), "The uploaded logo file should be less than 1MB");
}
else if (!model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
else if (!vm.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
{
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
ModelState.AddModelError(nameof(vm.LogoFile), "The uploaded logo file needs to be an image");
}
else
{
var formFile = await model.LogoFile.Bufferize();
var formFile = await vm.LogoFile.Bufferize();
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
{
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
ModelState.AddModelError(nameof(vm.LogoFile), "The uploaded logo file needs to be an image");
}
else
{
model.LogoFile = formFile;
vm.LogoFile = formFile;
// delete existing file
if (!string.IsNullOrEmpty(settings.LogoFileId))
if (!string.IsNullOrEmpty(theme.LogoFileId))
{
await _fileService.RemoveFile(settings.LogoFileId, userId);
await _fileService.RemoveFile(theme.LogoFileId, userId);
}
// add new file
try
{
var storedFile = await _fileService.AddFile(model.LogoFile, userId);
settings.LogoFileId = storedFile.Id;
var storedFile = await _fileService.AddFile(vm.LogoFile, userId);
vm.LogoFileId = theme.LogoFileId = storedFile.Id;
settingsChanged = true;
}
catch (Exception e)
{
ModelState.AddModelError(nameof(settings.LogoFile), $"Could not save logo: {e.Message}");
ModelState.AddModelError(nameof(vm.LogoFile), $"Could not save logo: {e.Message}");
}
}
}
}
else if (RemoveLogoFile && !string.IsNullOrEmpty(settings.LogoFileId))
else if (RemoveLogoFile && !string.IsNullOrEmpty(theme.LogoFileId))
{
await _fileService.RemoveFile(settings.LogoFileId, userId);
settings.LogoFileId = null;
await _fileService.RemoveFile(theme.LogoFileId, userId);
vm.LogoFileId = theme.LogoFileId = null;
settingsChanged = true;
}
if (model.CustomTheme && !string.IsNullOrEmpty(model.CustomThemeCssUri) && !Uri.IsWellFormedUriString(model.CustomThemeCssUri, UriKind.RelativeOrAbsolute))
if (vm.CustomTheme && !string.IsNullOrEmpty(vm.CustomThemeCssUri) && !Uri.IsWellFormedUriString(vm.CustomThemeCssUri, UriKind.RelativeOrAbsolute))
{
ModelState.AddModelError(nameof(settings.CustomThemeCssUri), "Please provide a non-empty theme URI");
ModelState.AddModelError(nameof(theme.CustomThemeCssUri), "Please provide a non-empty theme URI");
}
else if (settings.CustomThemeCssUri != model.CustomThemeCssUri)
else if (theme.CustomThemeCssUri != vm.CustomThemeCssUri)
{
settings.CustomThemeCssUri = model.CustomThemeCssUri;
theme.CustomThemeCssUri = vm.CustomThemeCssUri;
settingsChanged = true;
}
if (settings.CustomThemeExtension != model.CustomThemeExtension)
if (theme.CustomThemeExtension != vm.CustomThemeExtension)
{
// Require a custom theme to be defined in that case
if (string.IsNullOrEmpty(model.CustomThemeCssUri) && string.IsNullOrEmpty(settings.CustomThemeFileId))
if (string.IsNullOrEmpty(vm.CustomThemeCssUri) && string.IsNullOrEmpty(theme.CustomThemeFileId))
{
ModelState.AddModelError(nameof(settings.CustomThemeFile), "Please provide a custom theme");
ModelState.AddModelError(nameof(vm.CustomThemeFile), "Please provide a custom theme");
}
else
{
settings.CustomThemeExtension = model.CustomThemeExtension;
theme.CustomThemeExtension = vm.CustomThemeExtension;
settingsChanged = true;
}
}
if (settings.CustomTheme != model.CustomTheme)
if (theme.CustomTheme != vm.CustomTheme)
{
settings.CustomTheme = model.CustomTheme;
theme.CustomTheme = vm.CustomTheme;
settingsChanged = true;
}
if (settingsChanged)
{
await _SettingsRepository.UpdateSetting(settings);
TempData[WellKnownTempData.SuccessMessage] = "Theme settings updated successfully";
await _SettingsRepository.UpdateSetting(theme);
TempData[WellKnownTempData.SuccessMessage] = "Settings updated successfully";
}
return View(settings);
return View(vm);
}
[Route("server/emails")]
[HttpGet("server/emails")]
public async Task<IActionResult> Emails()
{
var data = (await _SettingsRepository.GetSettingAsync<EmailSettings>()) ?? new EmailSettings();
return View(new EmailsViewModel(data));
var email = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
var vm = new ServerEmailsViewModel(email)
{
EnableStoresToUseServerEmailSettings = !_policiesSettings.DisableStoresToUseServerEmailSettings
};
return View(vm);
}
[Route("server/emails")]
[HttpPost]
public async Task<IActionResult> Emails(EmailsViewModel model, string command)
[HttpPost("server/emails")]
public async Task<IActionResult> Emails(ServerEmailsViewModel model, string command)
{
if (command == "Test")
{
@ -1156,8 +1215,10 @@ namespace BTCPayServer.Controllers
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
if (!ModelState.IsValid)
return View(model);
var serverSettings = await _SettingsRepository.GetSettingAsync<ServerSettings>();
var serverName = string.IsNullOrEmpty(serverSettings?.ServerName) ? "BTCPay Server" : serverSettings.ServerName;
using (var client = await model.Settings.CreateSmtpClient())
using (var message = model.Settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), "BTCPay test", "BTCPay test", false))
using (var message = model.Settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), $"{serverName}: Email test", "You received it, the BTCPay Server SMTP settings work.", false))
{
await client.SendAsync(message);
await client.DisconnectAsync(true);
@ -1170,6 +1231,13 @@ namespace BTCPayServer.Controllers
}
return View(model);
}
if (_policiesSettings.DisableStoresToUseServerEmailSettings == model.EnableStoresToUseServerEmailSettings)
{
_policiesSettings.DisableStoresToUseServerEmailSettings = !model.EnableStoresToUseServerEmailSettings;
await _SettingsRepository.UpdateSetting(_policiesSettings);
}
if (command == "ResetPassword")
{
var settings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
@ -1178,22 +1246,22 @@ namespace BTCPayServer.Controllers
TempData[WellKnownTempData.SuccessMessage] = "Email server password reset";
return RedirectToAction(nameof(Emails));
}
else // if (command == "Save")
// save
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
{
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
{
ModelState.AddModelError("Settings.From", "Invalid email");
return View(model);
}
var oldSettings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
if (new EmailsViewModel(oldSettings).PasswordSet)
{
model.Settings.Password = oldSettings.Password;
}
await _SettingsRepository.UpdateSetting(model.Settings);
TempData[WellKnownTempData.SuccessMessage] = "Email settings saved";
return RedirectToAction(nameof(Emails));
ModelState.AddModelError("Settings.From", "Invalid email");
return View(model);
}
var oldSettings = await _SettingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
if (new ServerEmailsViewModel(oldSettings).PasswordSet)
{
model.Settings.Password = oldSettings.Password;
}
await _SettingsRepository.UpdateSetting(model.Settings);
TempData[WellKnownTempData.SuccessMessage] = "Email settings saved";
return RedirectToAction(nameof(Emails));
}
[Route("server/logs/{file?}")]

@ -10,10 +10,9 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Models;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Rating;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.Services;
using BTCPayServer.Services.Rates;
using Microsoft.AspNetCore.Authorization;
@ -23,7 +22,6 @@ using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json.Linq;
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
using PayoutData = BTCPayServer.Data.PayoutData;
using PullPaymentData = BTCPayServer.Data.PullPaymentData;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers
@ -40,6 +38,8 @@ namespace BTCPayServer.Controllers
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
private readonly IAuthorizationService _authorizationService;
private readonly PayoutProcessorService _payoutProcessorService;
private readonly IEnumerable<IPayoutProcessorFactory> _payoutProcessorFactories;
public StoreData CurrentStore
{
@ -55,6 +55,8 @@ namespace BTCPayServer.Controllers
DisplayFormatter displayFormatter,
PullPaymentHostedService pullPaymentHostedService,
ApplicationDbContextFactory dbContextFactory,
PayoutProcessorService payoutProcessorService,
IEnumerable<IPayoutProcessorFactory> payoutProcessorFactories,
BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
IAuthorizationService authorizationService)
{
@ -66,8 +68,10 @@ namespace BTCPayServer.Controllers
_dbContextFactory = dbContextFactory;
_jsonSerializerSettings = jsonSerializerSettings;
_authorizationService = authorizationService;
_payoutProcessorService = payoutProcessorService;
_payoutProcessorFactories = payoutProcessorFactories;
}
[HttpGet("stores/{storeId}/pull-payments/new")]
[Authorize(Policy = Policies.CanCreateNonApprovedPullPayments, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> NewPullPayment(string storeId)
@ -83,7 +87,7 @@ namespace BTCPayServer.Controllers
Message = "You must enable at least one payment method before creating a pull payment.",
Severity = StatusMessageModel.StatusSeverity.Error
});
return RedirectToAction(nameof(UIStoresController.Dashboard), "UIStores", new { storeId });
return RedirectToAction(nameof(UIStoresController.Index), "UIStores", new { storeId });
}
return View(new NewPullPaymentModel
@ -161,6 +165,7 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(PullPayments), new { storeId = storeId });
}
[Authorize(Policy = Policies.CanViewPullPayments, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpGet("stores/{storeId}/pull-payments")]
public async Task<IActionResult> PullPayments(
string storeId,
@ -199,7 +204,7 @@ namespace BTCPayServer.Controllers
Message = "You must enable at least one payment method before creating a pull payment.",
Severity = StatusMessageModel.StatusSeverity.Error
});
return RedirectToAction(nameof(UIStoresController.Dashboard), "UIStores", new { storeId });
return RedirectToAction(nameof(UIStoresController.Index), "UIStores", new { storeId });
}
var vm = this.ParseListQuery(new PullPaymentsModel
@ -286,6 +291,7 @@ namespace BTCPayServer.Controllers
return NotFound();
vm.PaymentMethods = await _payoutHandlers.GetSupportedPaymentMethods(HttpContext.GetStoreData());
vm.HasPayoutProcessor = await HasPayoutProcessor(storeId, vm.PaymentMethodId);
var paymentMethodId = PaymentMethodId.Parse(vm.PaymentMethodId);
var handler = _payoutHandlers
.FindPayoutHandler(paymentMethodId);
@ -369,7 +375,7 @@ namespace BTCPayServer.Controllers
break;
}
if (command == "approve-pay")
if (command == "approve-pay" && !vm.HasPayoutProcessor)
{
goto case "pay";
}
@ -482,19 +488,21 @@ namespace BTCPayServer.Controllers
Message = "You must enable at least one payment method before creating a payout.",
Severity = StatusMessageModel.StatusSeverity.Error
});
return RedirectToAction(nameof(UIStoresController.Dashboard), "UIStores", new { storeId });
return RedirectToAction(nameof(UIStoresController.Index), "UIStores", new { storeId });
}
paymentMethodId ??= paymentMethods.First().ToString();
var vm = this.ParseListQuery(new PayoutsModel
{
PaymentMethods = paymentMethods,
PaymentMethodId = paymentMethodId ?? paymentMethods.First().ToString(),
PaymentMethodId = paymentMethodId,
PullPaymentId = pullPaymentId,
PayoutState = payoutState,
Skip = skip,
Count = count
Count = count,
Payouts = new List<PayoutsModel.PayoutModel>(),
HasPayoutProcessor = await HasPayoutProcessor(storeId, paymentMethodId)
});
vm.Payouts = new List<PayoutsModel.PayoutModel>();
await using var ctx = _dbContextFactory.CreateContext();
var payoutRequest =
ctx.Payouts.Where(p => p.StoreDataId == storeId && (p.PullPaymentDataId == null || !p.PullPaymentData.Archived));
@ -576,5 +584,13 @@ namespace BTCPayServer.Controllers
}
return View(vm);
}
private async Task<bool> HasPayoutProcessor(string storeId, string paymentMethodId)
{
var pmId = PaymentMethodId.Parse(paymentMethodId);
var processors = await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery { Stores = [storeId], PaymentMethods = [paymentMethodId] });
return _payoutProcessorFactories.Any(factory => factory.GetSupportedPaymentMethods().Contains(pmId)) && processors.Any();
}
}
}

@ -1,12 +1,15 @@
#nullable enable
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Components.StoreLightningBalance;
using BTCPayServer.Components.StoreNumbers;
using BTCPayServer.Components.StoreRecentInvoices;
using BTCPayServer.Components.StoreRecentTransactions;
using BTCPayServer.Data;
using BTCPayServer.Models.StoreViewModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers
@ -14,9 +17,13 @@ namespace BTCPayServer.Controllers
public partial class UIStoresController
{
[HttpGet("{storeId}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> Dashboard()
{
var store = CurrentStore;
if (store is null)
return NotFound();
var storeBlob = store.GetStoreBlob();
AddPaymentMethods(store, storeBlob,
@ -38,16 +45,17 @@ namespace BTCPayServer.Controllers
};
// Widget data
if (!vm.WalletEnabled && !vm.LightningEnabled)
if (vm is { WalletEnabled: false, LightningEnabled: false })
return View(vm);
var userId = GetUserId();
if (userId is null)
return NotFound();
var apps = await _appService.GetAllApps(userId, false, store.Id);
foreach (var app in apps)
{
var appData = await _appService.GetAppDataIfOwner(userId, app.Id);
var appData = await _appService.GetAppData(userId, app.Id);
vm.Apps.Add(appData);
}
@ -55,6 +63,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/dashboard/{cryptoCode}/lightning/balance")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult LightningBalance(string storeId, string cryptoCode)
{
var store = HttpContext.GetStoreData();
@ -66,6 +75,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/dashboard/{cryptoCode}/numbers")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult StoreNumbers(string storeId, string cryptoCode)
{
var store = HttpContext.GetStoreData();
@ -77,6 +87,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/dashboard/{cryptoCode}/recent-transactions")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult RecentTransactions(string storeId, string cryptoCode)
{
var store = HttpContext.GetStoreData();
@ -88,6 +99,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/dashboard/{cryptoCode}/recent-invoices")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult RecentInvoices(string storeId, string cryptoCode)
{
var store = HttpContext.GetStoreData();

@ -7,11 +7,11 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Models;
using BTCPayServer.Services.Mails;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MimeKit;
@ -45,6 +45,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/emails")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> StoreEmails(string storeId, StoreEmailRuleViewModel vm, string command)
{
vm.Rules ??= new List<StoreEmailRule>();
@ -120,7 +121,7 @@ namespace BTCPayServer.Controllers
.Where(o => o != null)
.ToArray();
emailSender.SendEmail(recipients.ToArray(), null, null, $"({store.StoreName} test) {rule.Subject}", rule.Body);
emailSender.SendEmail(recipients.ToArray(), null, null, $"[TEST] {rule.Subject}", rule.Body);
message += "Test email sent — please verify you received it.";
}
else
@ -187,33 +188,41 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/email-settings")]
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command)
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command, [FromForm] bool useCustomSMTP = false)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var emailSender = await _emailSenderFactory.GetEmailSender(store.Id) as StoreEmailSender;
var fallbackSettings = await _emailSenderFactory.GetEmailSender(store.Id) is StoreEmailSender { FallbackSender: not null } storeSender
model.FallbackSettings = await _emailSenderFactory.GetEmailSender(store.Id) is StoreEmailSender { FallbackSender: not null } storeSender
? await storeSender.FallbackSender.GetEmailSettings()
: null;
model.FallbackSettings = fallbackSettings;
if (model.FallbackSettings is null) useCustomSMTP = true;
ViewBag.UseCustomSMTP = useCustomSMTP;
if (useCustomSMTP)
{
model.Settings.Validate("Settings.", ModelState);
}
if (command == "Test")
{
try
{
if (model.PasswordSet)
if (useCustomSMTP)
{
model.Settings.Password = store.GetStoreBlob().EmailSettings.Password;
if (model.PasswordSet)
{
model.Settings.Password = store.GetStoreBlob().EmailSettings.Password;
}
}
model.Settings.Validate("Settings.", ModelState);
if (string.IsNullOrEmpty(model.TestEmail))
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
if (!ModelState.IsValid)
return View(model);
using var client = await model.Settings.CreateSmtpClient();
var message = model.Settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), "BTCPay test", "BTCPay test", false);
var settings = useCustomSMTP ? model.Settings : model.FallbackSettings;
using var client = await settings.CreateSmtpClient();
var message = settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), $"{store.StoreName}: Email test", "You received it, the BTCPay Server SMTP settings work.", false);
await client.SendAsync(message);
await client.DisconnectAsync(true);
TempData[WellKnownTempData.SuccessMessage] = $"Email sent to {model.TestEmail}. Please verify you received it.";
@ -231,17 +240,17 @@ namespace BTCPayServer.Controllers
store.SetStoreBlob(storeBlob);
await _Repo.UpdateStore(store);
TempData[WellKnownTempData.SuccessMessage] = "Email server password reset";
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
}
else // if (command == "Save")
if (useCustomSMTP)
{
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
{
ModelState.AddModelError("Settings.From", "Invalid email");
return View(model);
}
if (!ModelState.IsValid)
return View(model);
var storeBlob = store.GetStoreBlob();
if (storeBlob.EmailSettings != null && new EmailsViewModel(storeBlob.EmailSettings, fallbackSettings).PasswordSet)
if (storeBlob.EmailSettings != null && new EmailsViewModel(storeBlob.EmailSettings, model.FallbackSettings).PasswordSet)
{
model.Settings.Password = storeBlob.EmailSettings.Password;
}
@ -249,8 +258,8 @@ namespace BTCPayServer.Controllers
store.SetStoreBlob(storeBlob);
await _Repo.UpdateStore(store);
TempData[WellKnownTempData.SuccessMessage] = "Email settings modified";
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
}
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
}
private static async Task<bool> IsSetupComplete(IEmailSender emailSender)

@ -4,10 +4,12 @@ using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBitcoin.DataEncoders;
@ -46,6 +48,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/webhooks/new")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult NewWebhook()
{
return View(nameof(ModifyWebhook), new EditWebhookViewModel
@ -58,6 +61,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/webhooks/{webhookId}/remove")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> DeleteWebhook(string webhookId)
{
var webhook = await _Repo.GetWebhook(CurrentStore.Id, webhookId);
@ -68,6 +72,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/webhooks/{webhookId}/remove")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> DeleteWebhookPost(string webhookId)
{
var webhook = await _Repo.GetWebhook(CurrentStore.Id, webhookId);
@ -80,6 +85,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/webhooks/new")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> NewWebhook(string storeId, EditWebhookViewModel viewModel)
{
if (!ModelState.IsValid)
@ -91,6 +97,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/webhooks/{webhookId}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ModifyWebhook(string webhookId)
{
var webhook = await _Repo.GetWebhook(CurrentStore.Id, webhookId);
@ -107,6 +114,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/webhooks/{webhookId}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ModifyWebhook(string webhookId, EditWebhookViewModel viewModel)
{
var webhook = await _Repo.GetWebhook(CurrentStore.Id, webhookId);
@ -121,6 +129,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/webhooks/{webhookId}/test")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> TestWebhook(string webhookId)
{
var webhook = await _Repo.GetWebhook(CurrentStore.Id, webhookId);
@ -131,6 +140,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/webhooks/{webhookId}/test")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> TestWebhook(string webhookId, TestWebhookViewModel viewModel, CancellationToken cancellationToken)
{
var result = await WebhookNotificationManager.TestWebhook(CurrentStore.Id, webhookId, viewModel.Type, cancellationToken);
@ -148,6 +158,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> RedeliverWebhook(string webhookId, string deliveryId)
{
var delivery = await _Repo.GetWebhookDelivery(CurrentStore.Id, webhookId, deliveryId);
@ -168,6 +179,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> WebhookDelivery(string webhookId, string deliveryId)
{
var delivery = await _Repo.GetWebhookDelivery(CurrentStore.Id, webhookId, deliveryId);

@ -5,7 +5,7 @@ using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Components.StoreLightningBalance;
using BTCPayServer.Client;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Lightning;
@ -14,7 +14,7 @@ using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
@ -22,8 +22,8 @@ namespace BTCPayServer.Controllers
{
public partial class UIStoresController
{
[HttpGet("{storeId}/lightning/{cryptoCode}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult Lightning(string storeId, string cryptoCode)
{
var store = HttpContext.GetStoreData();
@ -85,6 +85,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/lightning/{cryptoCode}/setup")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult SetupLightningNode(string storeId, string cryptoCode)
{
var store = HttpContext.GetStoreData();
@ -101,6 +102,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/lightning/{cryptoCode}/setup")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> SetupLightningNode(string storeId, LightningNodeViewModel vm, string command, string cryptoCode)
{
vm.CryptoCode = cryptoCode;
@ -217,6 +219,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/lightning/{cryptoCode}/settings")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult LightningSettings(string storeId, string cryptoCode)
{
var store = HttpContext.GetStoreData();
@ -257,6 +260,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/lightning/{cryptoCode}/settings")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> LightningSettings(LightningSettingsViewModel vm)
{
var store = HttpContext.GetStoreData();
@ -310,6 +314,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/lightning/{cryptoCode}/status")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> SetLightningNodeEnabled(string storeId, string cryptoCode, bool enabled)
{
var store = HttpContext.GetStoreData();

@ -1,5 +1,4 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Text;
@ -8,12 +7,12 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
@ -27,6 +26,7 @@ namespace BTCPayServer.Controllers
public partial class UIStoresController
{
[HttpGet("{storeId}/onchain/{cryptoCode}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public ActionResult SetupWallet(WalletSetupViewModel vm)
{
var checkResult = IsAvailable(vm.CryptoCode, out var store, out _);
@ -42,6 +42,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/onchain/{cryptoCode}/import/{method?}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ImportWallet(WalletSetupViewModel vm)
{
var checkResult = IsAvailable(vm.CryptoCode, out _, out var network);
@ -71,6 +72,7 @@ namespace BTCPayServer.Controllers
[HttpPost("{storeId}/onchain/{cryptoCode}/modify")]
[HttpPost("{storeId}/onchain/{cryptoCode}/import/{method}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> UpdateWallet(WalletSetupViewModel vm)
{
var checkResult = IsAvailable(vm.CryptoCode, out var store, out var network);
@ -197,6 +199,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/onchain/{cryptoCode}/generate/{method?}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> GenerateWallet(WalletSetupViewModel vm)
{
var checkResult = IsAvailable(vm.CryptoCode, out _, out var network);
@ -235,8 +238,11 @@ namespace BTCPayServer.Controllers
return View(vm.ViewName, vm);
}
internal GenerateWalletResponse GenerateWalletResponse;
[HttpPost("{storeId}/onchain/{cryptoCode}/generate/{method}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> GenerateWallet(string storeId, string cryptoCode, WalletSetupMethod method, WalletSetupRequest request)
{
var checkResult = IsAvailable(cryptoCode, out _, out var network);
@ -356,6 +362,7 @@ namespace BTCPayServer.Controllers
// The purpose of this action is to show the user a success message, which confirms
// that the store settings have been updated after generating a new wallet.
[HttpGet("{storeId}/onchain/{cryptoCode}/generate/confirm")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public ActionResult GenerateWalletConfirm(string storeId, string cryptoCode)
{
var checkResult = IsAvailable(cryptoCode, out _, out var network);
@ -371,6 +378,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/onchain/{cryptoCode}/settings")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> WalletSettings(string storeId, string cryptoCode)
{
var checkResult = IsAvailable(cryptoCode, out var store, out var network);
@ -440,6 +448,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/onchain/{cryptoCode}/settings/wallet")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> UpdateWalletSettings(WalletSettingsViewModel vm)
{
var checkResult = IsAvailable(vm.CryptoCode, out var store, out _);
@ -549,6 +558,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/onchain/{cryptoCode}/settings/payment")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> UpdatePaymentSettings(WalletSettingsViewModel vm)
{
var checkResult = IsAvailable(vm.CryptoCode, out var store, out _);
@ -612,6 +622,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/onchain/{cryptoCode}/seed")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> WalletSeed(string storeId, string cryptoCode, CancellationToken cancellationToken = default)
{
var checkResult = IsAvailable(cryptoCode, out var store, out var network);
@ -658,6 +669,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/onchain/{cryptoCode}/replace")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public ActionResult ReplaceWallet(string storeId, string cryptoCode)
{
var checkResult = IsAvailable(cryptoCode, out var store, out var network);
@ -677,6 +689,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/onchain/{cryptoCode}/replace")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult ConfirmReplaceWallet(string storeId, string cryptoCode)
{
var checkResult = IsAvailable(cryptoCode, out var store, out _);
@ -695,6 +708,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/onchain/{cryptoCode}/delete")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public ActionResult DeleteWallet(string storeId, string cryptoCode)
{
var checkResult = IsAvailable(cryptoCode, out var store, out var network);
@ -714,6 +728,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/onchain/{cryptoCode}/delete")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ConfirmDeleteWallet(string storeId, string cryptoCode)
{
var checkResult = IsAvailable(cryptoCode, out var store, out var network);

@ -1,22 +1,19 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Amazon.S3.Transfer;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers
{
public partial class UIStoresController
{
[Route("{storeId}/roles")]
[HttpGet("{storeId}/roles")]
public async Task<IActionResult> ListRoles(
string storeId,
[FromServices] StoreRepository storeRepository,
@ -24,24 +21,21 @@ namespace BTCPayServer.Controllers
string sortOrder = null
)
{
var roles = await storeRepository.GetStoreRoles(storeId, true);
var defaultRole = (await storeRepository.GetDefaultRole()).Role;
model ??= new RolesViewModel();
model.DefaultRole = defaultRole;
model.DefaultRole = (await storeRepository.GetDefaultRole()).Role;
var roles = await storeRepository.GetStoreRoles(storeId, false, false);
if (sortOrder != null)
switch (sortOrder)
{
switch (sortOrder)
{
case "desc":
ViewData["NextRoleSortOrder"] = "asc";
roles = roles.OrderByDescending(user => user.Role).ToArray();
break;
case "asc":
roles = roles.OrderBy(user => user.Role).ToArray();
ViewData["NextRoleSortOrder"] = "desc";
break;
}
case "desc":
ViewData["NextRoleSortOrder"] = "asc";
roles = roles.OrderByDescending(user => user.Role).ToArray();
break;
case "asc":
roles = roles.OrderBy(user => user.Role).ToArray();
ViewData["NextRoleSortOrder"] = "desc";
break;
}
model.Roles = roles.Skip(model.Skip).Take(model.Count).ToList();
@ -50,6 +44,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/roles/{role}")]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> CreateOrEditRole(
string storeId,
[FromServices] StoreRepository storeRepository,
@ -60,19 +55,19 @@ namespace BTCPayServer.Controllers
ModelState.Remove(nameof(role));
return View(new UpdateRoleViewModel());
}
else
var roleData = await storeRepository.GetStoreRole(new StoreRoleId(storeId, role));
if (roleData == null)
return NotFound();
return View(new UpdateRoleViewModel
{
var roleData = await storeRepository.GetStoreRole(new StoreRoleId(storeId, role));
if (roleData == null)
return NotFound();
return View(new UpdateRoleViewModel()
{
Policies = roleData.Permissions,
Role = roleData.Role
});
}
}
Policies = roleData.Permissions,
Role = roleData.Role
});
}
[HttpPost("{storeId}/roles/{role}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> CreateOrEditRole(
string storeId,
[FromServices] StoreRepository storeRepository,
@ -119,10 +114,9 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(ListRoles), new { storeId });
}
[HttpGet("{storeId}/roles/{role}/delete")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> DeleteRole(
string storeId,
[FromServices] StoreRepository storeRepository,
@ -142,6 +136,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/roles/{role}/delete")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> DeleteRolePost(
string storeId,
[FromServices] StoreRepository storeRepository,

@ -0,0 +1,172 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
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.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Rating;
using BTCPayServer.Security.Bitpay;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.Options;
using NBitcoin;
using NBitcoin.DataEncoders;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers
{
public partial class UIStoresController
{
[HttpGet("{storeId}/users")]
public async Task<IActionResult> StoreUsers()
{
var vm = new StoreUsersViewModel { Role = StoreRoleId.Employee.Role };
await FillUsers(vm);
return View(vm);
}
[HttpPost("{storeId}/users")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> StoreUsers(string storeId, StoreUsersViewModel vm)
{
await FillUsers(vm);
if (!ModelState.IsValid)
{
return View(vm);
}
var roles = await _Repo.GetStoreRoles(CurrentStore.Id);
if (roles.All(role => role.Id != vm.Role))
{
ModelState.AddModelError(nameof(vm.Role), "Invalid role");
return View(vm);
}
var user = await _UserManager.FindByEmailAsync(vm.Email);
var isExistingUser = user is not null;
var isExistingStoreUser = isExistingUser && await _Repo.GetStoreUser(storeId, user!.Id) is not null;
var successInfo = string.Empty;
if (user == null)
{
user = new ApplicationUser
{
UserName = vm.Email,
Email = vm.Email,
RequiresEmailConfirmation = _policiesSettings.RequiresConfirmedEmail,
RequiresApproval = _policiesSettings.RequiresUserApproval,
Created = DateTimeOffset.UtcNow
};
var result = await _UserManager.CreateAsync(user);
if (result.Succeeded)
{
var tcs = new TaskCompletionSource<Uri>();
var currentUser = await _UserManager.GetUserAsync(HttpContext.User);
_eventAggregator.Publish(new UserRegisteredEvent
{
RequestUri = Request.GetAbsoluteRootUri(),
Kind = UserRegisteredEventKind.Invite,
User = user,
InvitedByUser = currentUser,
CallbackUrlGenerated = tcs
});
var callbackUrl = await tcs.Task;
var settings = await _settingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
var info = settings.IsComplete()
? "An invitation email has been sent.<br/>You may alternatively"
: "An invitation email has not been sent, because the server does not have an email server configured.<br/> You need to";
successInfo = $"{info} share this link with them: <a class='alert-link' href='{callbackUrl}'>{callbackUrl}</a>";
}
else
{
ModelState.AddModelError(nameof(vm.Email), "User could not be invited");
return View(vm);
}
}
var roleId = await _Repo.ResolveStoreRoleId(storeId, vm.Role);
var action = isExistingUser
? isExistingStoreUser ? "updated" : "added"
: "invited";
if (await _Repo.AddOrUpdateStoreUser(CurrentStore.Id, user.Id, roleId))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
AllowDismiss = false,
Html = $"User {action} successfully." + (string.IsNullOrEmpty(successInfo) ? "" : $" {successInfo}")
});
return RedirectToAction(nameof(StoreUsers));
}
ModelState.AddModelError(nameof(vm.Email), $"The user could not be {action}");
return View(vm);
}
[HttpPost("{storeId}/users/{userId}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> UpdateStoreUser(string storeId, string userId, StoreUsersViewModel.StoreUserViewModel vm)
{
var roleId = await _Repo.ResolveStoreRoleId(storeId, vm.Role);
var storeUsers = await _Repo.GetStoreUsers(storeId);
var user = storeUsers.First(user => user.Id == userId);
var isOwner = user.StoreRole.Id == StoreRoleId.Owner.Id;
var isLastOwner = isOwner && storeUsers.Count(u => u.StoreRole.Id == StoreRoleId.Owner.Id) == 1;
if (isLastOwner && roleId != StoreRoleId.Owner)
TempData[WellKnownTempData.ErrorMessage] = $"User {user.Email} is the last owner. Their role cannot be changed.";
else if (await _Repo.AddOrUpdateStoreUser(storeId, userId, roleId))
TempData[WellKnownTempData.SuccessMessage] = $"The role of {user.Email} has been changed to {vm.Role}.";
return RedirectToAction(nameof(StoreUsers), new { storeId, userId });
}
[HttpPost("{storeId}/users/{userId}/delete")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> DeleteStoreUser(string storeId, string userId)
{
if (await _Repo.RemoveStoreUser(storeId, userId))
TempData[WellKnownTempData.SuccessMessage] = "User removed successfully.";
else
TempData[WellKnownTempData.ErrorMessage] = "Removing this user would result in the store having no owner.";
return RedirectToAction(nameof(StoreUsers), new { storeId, userId });
}
private async Task FillUsers(StoreUsersViewModel vm)
{
var users = await _Repo.GetStoreUsers(CurrentStore.Id);
vm.StoreId = CurrentStore.Id;
vm.Users = users.Select(u => new StoreUsersViewModel.StoreUserViewModel()
{
Email = u.Email,
Id = u.Id,
Role = u.StoreRole.Role
}).ToList();
}
}
}

@ -8,11 +8,12 @@ using System.Threading;
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.Configuration;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Events;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
@ -39,10 +40,9 @@ using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers
{
[Route("stores")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[AutoValidateAntiforgeryToken]
public partial class UIStoresController : Controller
{
@ -62,7 +62,6 @@ namespace BTCPayServer.Controllers
PaymentMethodHandlerDictionary paymentMethodHandlerDictionary,
PoliciesSettings policiesSettings,
IAuthorizationService authorizationService,
EventAggregator eventAggregator,
AppService appService,
IFileService fileService,
WebhookSender webhookNotificationManager,
@ -72,7 +71,9 @@ namespace BTCPayServer.Controllers
IHtmlHelper html,
LightningClientFactoryService lightningClientFactoryService,
EmailSenderFactory emailSenderFactory,
WalletFileParsers onChainWalletParsers)
WalletFileParsers onChainWalletParsers,
SettingsRepository settingsRepository,
EventAggregator eventAggregator)
{
_RateFactory = rateFactory;
_Repo = repo;
@ -99,6 +100,8 @@ namespace BTCPayServer.Controllers
_lightningClientFactoryService = lightningClientFactoryService;
_emailSenderFactory = emailSenderFactory;
_onChainWalletParsers = onChainWalletParsers;
_settingsRepository = settingsRepository;
_eventAggregator = eventAggregator;
Html = html;
}
@ -112,6 +115,7 @@ namespace BTCPayServer.Controllers
readonly TokenRepository _TokenRepository;
readonly UserManager<ApplicationUser> _UserManager;
readonly RateFetcher _RateFactory;
readonly SettingsRepository _settingsRepository;
private readonly ExplorerClientProvider _ExplorerProvider;
private readonly LanguageService _LangService;
private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary;
@ -124,6 +128,7 @@ namespace BTCPayServer.Controllers
private readonly LightningClientFactoryService _lightningClientFactoryService;
private readonly EmailSenderFactory _emailSenderFactory;
private readonly WalletFileParsers _onChainWalletParsers;
private readonly EventAggregator _eventAggregator;
public string? GeneratedPairingCode { get; set; }
public WebhookSender WebhookNotificationManager { get; }
@ -145,97 +150,22 @@ namespace BTCPayServer.Controllers
if (string.IsNullOrEmpty(userId))
return Forbid();
var store = await _Repo.FindStore(storeId, userId);
var store = await _Repo.FindStore(storeId);
if (store is null)
{
return Forbid();
}
if (store.GetPermissionSet(userId).Contains(Policies.CanModifyStoreSettings, storeId))
return NotFound();
if ((await _authorizationService.AuthorizeAsync(User, Policies.CanModifyStoreSettings)).Succeeded)
{
return RedirectToAction("Dashboard", new { storeId });
}
if (store.GetPermissionSet(userId).Contains(Policies.CanViewInvoices, storeId))
if ((await _authorizationService.AuthorizeAsync(User, Policies.CanViewInvoices)).Succeeded)
{
return RedirectToAction("ListInvoices", "UIInvoice", new { storeId });
}
HttpContext.SetStoreData(store);
return View();
}
[HttpGet("{storeId}/users")]
public async Task<IActionResult> StoreUsers()
{
var vm = new StoreUsersViewModel { Role = StoreRoleId.Guest.Role };
await FillUsers(vm);
return View(vm);
}
private async Task FillUsers(StoreUsersViewModel vm)
{
var users = await _Repo.GetStoreUsers(CurrentStore.Id);
vm.StoreId = CurrentStore.Id;
vm.Users = users.Select(u => new StoreUsersViewModel.StoreUserViewModel()
{
Email = u.Email,
Id = u.Id,
Role = u.StoreRole.Role
}).ToList();
}
public StoreData CurrentStore => HttpContext.GetStoreData();
[HttpPost("{storeId}/users")]
public async Task<IActionResult> StoreUsers(string storeId, StoreUsersViewModel vm)
{
await FillUsers(vm);
if (!ModelState.IsValid)
{
return View(vm);
}
var user = await _UserManager.FindByEmailAsync(vm.Email);
if (user == null)
{
ModelState.AddModelError(nameof(vm.Email), "User not found");
return View(vm);
}
var roles = await _Repo.GetStoreRoles(CurrentStore.Id);
if (roles.All(role => role.Id != vm.Role))
{
ModelState.AddModelError(nameof(vm.Role), "Invalid role");
return View(vm);
}
var roleId = await _Repo.ResolveStoreRoleId(storeId, vm.Role);
if (!await _Repo.AddStoreUser(CurrentStore.Id, user.Id, roleId))
{
ModelState.AddModelError(nameof(vm.Email), "The user already has access to this store");
return View(vm);
}
TempData[WellKnownTempData.SuccessMessage] = "User added successfully.";
return RedirectToAction(nameof(StoreUsers));
}
[HttpGet("{storeId}/users/{userId}/delete")]
public async Task<IActionResult> DeleteStoreUser(string userId)
{
var user = await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
return View("Confirm", new ConfirmModel("Remove store user", $"This action will prevent <strong>{Html.Encode(user.Email)}</strong> from accessing this store and its settings. Are you sure?", "Remove"));
}
[HttpPost("{storeId}/users/{userId}/delete")]
public async Task<IActionResult> DeleteStoreUserPost(string storeId, string userId)
{
if (await _Repo.RemoveStoreUser(storeId, userId))
TempData[WellKnownTempData.SuccessMessage] = "User removed successfully.";
else
{
TempData[WellKnownTempData.ErrorMessage] = "Removing this user would result in the store having no owner.";
}
return RedirectToAction(nameof(StoreUsers), new { storeId, userId });
return Forbid();
}
public StoreData? CurrentStore => HttpContext.GetStoreData();
[HttpGet("{storeId}/rates")]
public IActionResult Rates()
@ -255,6 +185,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/rates")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> Rates(RatesViewModel model, string? command = null, string? storeId = null, CancellationToken cancellationToken = default)
{
if (command == "scripting-on")
@ -373,6 +304,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/rates/confirm")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult ShowRateRules(bool scripting)
{
return View("Confirm", new ConfirmModel
@ -387,6 +319,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/rates/confirm")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ShowRateRulesPost(bool scripting)
{
var blob = CurrentStore.GetStoreBlob();
@ -438,6 +371,7 @@ namespace BTCPayServer.Controllers
vm.CustomLogo = storeBlob.CustomLogo;
vm.SoundFileId = storeBlob.SoundFileId;
vm.HtmlTitle = storeBlob.HtmlTitle;
vm.SupportUrl = storeBlob.StoreSupportUrl;
vm.DisplayExpirationTimer = (int)storeBlob.DisplayExpirationTimer.TotalMinutes;
vm.ReceiptOptions = CheckoutAppearanceViewModel.ReceiptOptionsViewModel.Create(storeBlob.ReceiptOptions);
vm.AutoDetectLanguage = storeBlob.AutoDetectLanguage;
@ -486,6 +420,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/checkout")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> CheckoutAppearance(CheckoutAppearanceViewModel model, [FromForm] bool RemoveSoundFile = false)
{
bool needUpdate = false;
@ -613,6 +548,7 @@ namespace BTCPayServer.Controllers
blob.CustomLogo = model.CustomLogo;
blob.CustomCSS = model.CustomCSS;
blob.HtmlTitle = string.IsNullOrWhiteSpace(model.HtmlTitle) ? null : model.HtmlTitle;
blob.StoreSupportUrl = string.IsNullOrWhiteSpace(model.SupportUrl) ? null : model.SupportUrl.IsValidEmail() ? $"mailto:{model.SupportUrl}" : model.SupportUrl;
blob.DisplayExpirationTimer = TimeSpan.FromMinutes(model.DisplayExpirationTimer);
blob.AutoDetectLanguage = model.AutoDetectLanguage;
blob.DefaultLang = model.DefaultLang;
@ -704,7 +640,6 @@ namespace BTCPayServer.Controllers
Id = store.Id,
StoreName = store.StoreName,
StoreWebsite = store.StoreWebsite,
StoreSupportUrl = storeBlob.StoreSupportUrl,
LogoFileId = storeBlob.LogoFileId,
CssFileId = storeBlob.CssFileId,
BrandColor = storeBlob.BrandColor,
@ -722,6 +657,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/settings")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> GeneralSettings(
GeneralSettingsViewModel model,
[FromForm] bool RemoveLogoFile = false,
@ -741,7 +677,6 @@ namespace BTCPayServer.Controllers
}
var blob = CurrentStore.GetStoreBlob();
blob.StoreSupportUrl = model.StoreSupportUrl;
blob.AnyoneCanInvoice = model.AnyoneCanCreateInvoice;
blob.NetworkFeeMode = model.NetworkFeeMode;
blob.PaymentTolerance = model.PaymentTolerance;
@ -863,7 +798,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/archive")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettings)]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ToggleArchive(string storeId)
{
CurrentStore.Archived = !CurrentStore.Archived;
@ -880,12 +815,14 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/delete")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult DeleteStore(string storeId)
{
return View("Confirm", new ConfirmModel("Delete store", "The store will be permanently deleted. This action will also delete all invoices, apps and data associated with the store. Are you sure?", "Delete"));
}
[HttpPost("{storeId}/delete")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> DeleteStorePost(string storeId)
{
await _Repo.DeleteStore(CurrentStore.Id);
@ -924,6 +861,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/tokens")]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ListTokens()
{
var model = new TokensViewModel();
@ -945,6 +883,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/tokens/{tokenId}/revoke")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> RevokeToken(string tokenId)
{
var token = await _TokenRepository.GetToken(tokenId);
@ -954,6 +893,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/tokens/{tokenId}/revoke")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> RevokeTokenConfirm(string tokenId)
{
var token = await _TokenRepository.GetToken(tokenId);
@ -967,6 +907,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/tokens/{tokenId}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ShowToken(string tokenId)
{
var token = await _TokenRepository.GetToken(tokenId);
@ -976,6 +917,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/tokens/create")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult CreateToken(string storeId)
{
var model = new CreateTokenViewModel();
@ -987,6 +929,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/tokens/create")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> CreateToken(string storeId, CreateTokenViewModel model)
{
if (!ModelState.IsValid)
@ -1065,6 +1008,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("{storeId}/tokens/apikey")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> GenerateAPIKey(string storeId, string command = "")
{
var store = HttpContext.GetStoreData();
@ -1129,6 +1073,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("/api-access-request")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> Pair(string pairingCode, string storeId)
{
if (pairingCode == null)

@ -6,7 +6,6 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Rating;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
@ -35,7 +34,7 @@ namespace BTCPayServer.Controllers
_rateFactory = rateFactory;
}
[HttpGet()]
[HttpGet]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyStoreSettingsUnscoped)]
public async Task<IActionResult> ListStores(bool archived = false)
{

@ -151,13 +151,12 @@ namespace BTCPayServer.Controllers
WalletId walletId, WalletPSBTViewModel vm, string command = null)
{
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
var psbt = await vm.GetPSBT(network.NBitcoinNetwork);
var psbt = await vm.GetPSBT(network.NBitcoinNetwork, ModelState);
vm.BackUrl ??= HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath;
if (psbt is null || vm.InvalidPSBT)
{
ModelState.AddModelError(nameof(vm.PSBT), "Invalid PSBT");
return View("WalletSigningOptions", new WalletSigningOptionsModel
{
SigningContext = vm.SigningContext,
@ -241,10 +240,9 @@ namespace BTCPayServer.Controllers
vm.NBXSeedAvailable = await CanUseHotWallet() && derivationSchemeSettings.IsHotWallet;
vm.BackUrl ??= HttpContext.Request.GetTypedHeaders().Referer?.AbsolutePath;
var psbt = await vm.GetPSBT(network.NBitcoinNetwork);
var psbt = await vm.GetPSBT(network.NBitcoinNetwork, ModelState);
if (vm.InvalidPSBT)
{
ModelState.AddModelError(nameof(vm.PSBT), "Invalid PSBT");
return View(vm);
}
if (psbt is null)
@ -477,7 +475,7 @@ namespace BTCPayServer.Controllers
WalletId walletId, WalletPSBTViewModel vm, string command, CancellationToken cancellationToken = default)
{
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
PSBT psbt = await vm.GetPSBT(network.NBitcoinNetwork);
PSBT psbt = await vm.GetPSBT(network.NBitcoinNetwork, ModelState);
if (vm.InvalidPSBT || psbt is null)
{
if (vm.InvalidPSBT)
@ -637,16 +635,14 @@ namespace BTCPayServer.Controllers
WalletId walletId, WalletPSBTCombineViewModel vm)
{
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
var psbt = await vm.GetPSBT(network.NBitcoinNetwork);
var psbt = await vm.GetPSBT(network.NBitcoinNetwork, ModelState);
if (psbt == null)
{
ModelState.AddModelError(nameof(vm.PSBT), "Invalid PSBT");
return View(vm);
}
var sourcePSBT = vm.GetSourcePSBT(network.NBitcoinNetwork);
if (sourcePSBT == null)
var sourcePSBT = vm.GetSourcePSBT(network.NBitcoinNetwork, ModelState);
if (sourcePSBT is null)
{
ModelState.AddModelError(nameof(vm.OtherPSBT), "Invalid PSBT");
return View(vm);
}
sourcePSBT = sourcePSBT.Combine(psbt);

@ -32,6 +32,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using NBitcoin;
using NBXplorer;
@ -47,6 +48,8 @@ namespace BTCPayServer.Controllers
[Route("wallets")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[AutoValidateAntiforgeryToken]
//16mb psbts
[RequestFormLimits(ValueLengthLimit = FormReader.DefaultValueLengthLimit * 4)]
public partial class UIWalletsController : Controller
{
private StoreRepository Repository { get; }
@ -436,7 +439,7 @@ namespace BTCPayServer.Controllers
{
if (walletId?.StoreId == null)
return NotFound();
var store = await Repository.FindStore(walletId.StoreId, GetUserId());
var store = await Repository.FindStore(walletId.StoreId);
var paymentMethod = GetDerivationSchemeSettings(walletId);
if (paymentMethod == null || store is null)
return NotFound();
@ -456,11 +459,12 @@ namespace BTCPayServer.Controllers
};
if (bip21?.Any() is true)
{
var messagePresent = TempData.HasStatusMessage();
foreach (var link in bip21)
{
if (!string.IsNullOrEmpty(link))
{
await LoadFromBIP21(walletId, model, link, network);
await LoadFromBIP21(walletId, model, link, network, messagePresent);
}
}
}
@ -536,7 +540,6 @@ namespace BTCPayServer.Controllers
}
catch (Exception ex) { model.RateError = ex.Message; }
}
return View(model);
}
@ -561,7 +564,7 @@ namespace BTCPayServer.Controllers
{
if (walletId?.StoreId == null)
return NotFound();
var store = await Repository.FindStore(walletId.StoreId, GetUserId());
var store = await Repository.FindStore(walletId.StoreId);
if (store == null)
return NotFound();
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
@ -572,7 +575,7 @@ namespace BTCPayServer.Controllers
if (!string.IsNullOrEmpty(bip21))
{
vm.Outputs?.Clear();
await LoadFromBIP21(walletId, vm, bip21, network);
await LoadFromBIP21(walletId, vm, bip21, network, TempData.HasStatusMessage());
}
decimal transactionAmountSum = 0;
@ -735,7 +738,7 @@ namespace BTCPayServer.Controllers
foreach (var transactionOutput in vm.Outputs.Where(output => output.Labels?.Any() is true))
{
var labels = transactionOutput.Labels.Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
var walletObjectAddress = new WalletObjectId(walletId, WalletObjectData.Types.Address, transactionOutput.DestinationAddress.ToLowerInvariant());
var walletObjectAddress = new WalletObjectId(walletId, WalletObjectData.Types.Address, transactionOutput.DestinationAddress);
var obj = await WalletRepository.GetWalletObject(walletObjectAddress);
if (obj is null)
{
@ -867,7 +870,7 @@ namespace BTCPayServer.Controllers
private async Task LoadFromBIP21(WalletId walletId, WalletSendModel vm, string bip21,
BTCPayNetwork network)
BTCPayNetwork network, bool statusMessagePresent)
{
BitcoinAddress? address = null;
vm.Outputs ??= new();
@ -889,14 +892,18 @@ namespace BTCPayServer.Controllers
}
vm.Outputs.Add(output);
address = uriBuilder.Address;
if (!string.IsNullOrEmpty(uriBuilder.Label) || !string.IsNullOrEmpty(uriBuilder.Message))
// only set SetStatusMessageModel if there is not message already or there is label / message in uri builder
if (!statusMessagePresent)
{
TempData.SetStatusMessageModel(new StatusMessageModel
if (!string.IsNullOrEmpty(uriBuilder.Label) || !string.IsNullOrEmpty(uriBuilder.Message))
{
Severity = StatusMessageModel.StatusSeverity.Info,
Html =
$"Payment {(string.IsNullOrEmpty(uriBuilder.Label) ? string.Empty : $" to <strong>{uriBuilder.Label}</strong>")} {(string.IsNullOrEmpty(uriBuilder.Message) ? string.Empty : $" for <strong>{uriBuilder.Message}</strong>")}"
});
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Info,
Html =
$"Payment {(string.IsNullOrEmpty(uriBuilder.Label) ? string.Empty : $" to <strong>{uriBuilder.Label}</strong>")} {(string.IsNullOrEmpty(uriBuilder.Message) ? string.Empty : $" for <strong>{uriBuilder.Message}</strong>")}"
});
}
}
if (uriBuilder.TryGetPayjoinEndpoint(out _))

@ -134,7 +134,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
return raw.ToObject<ManualPayoutProof>();
}
public static void ParseProofType(byte[] proof, out JObject obj, out string type)
public static void ParseProofType(string proof, out JObject obj, out string type)
{
type = null;
if (proof is null)
@ -143,7 +143,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
return;
}
obj = JObject.Parse(Encoding.UTF8.GetString(proof));
obj = JObject.Parse(proof);
TryParseProofType(obj, out type);
}

@ -11,7 +11,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
Bolt11 = bolt11 ?? throw new ArgumentNullException(nameof(bolt11));
PaymentRequest = paymentRequest;
PaymentHash = paymentRequest.Hash;
Amount = paymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC);
Amount = paymentRequest.MinimumAmount.MilliSatoshi == LightMoney.Zero ? null: paymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC);
}
public override string ToString()

@ -73,7 +73,11 @@ namespace BTCPayServer.Data.Payouts.LightningLike
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var t = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, cancellationToken);
var info = (LNURLPayRequest)(await LNURL.LNURL.FetchInformation(lnurl, CreateClient(lnurl), t.Token));
var rawInfo = await LNURL.LNURL.FetchInformation(lnurl, CreateClient(lnurl), t.Token);
if(rawInfo is null)
return (null, "The LNURL / Lightning Address provided was not online.");
if(rawInfo is not LNURLPayRequest info)
return (null, "The LNURL was not a valid LNURL Pay request.");
lnurlTag = info.Tag;
}
@ -143,9 +147,27 @@ namespace BTCPayServer.Data.Payouts.LightningLike
return Task.CompletedTask;
}
public Task<decimal> GetMinimumPayoutAmount(PaymentMethodId paymentMethodId, IClaimDestination claimDestination)
public async Task<decimal> GetMinimumPayoutAmount(PaymentMethodId paymentMethod, IClaimDestination claimDestination)
{
return Task.FromResult(Money.Satoshis(1).ToDecimal(MoneyUnit.BTC));
if(claimDestination is LNURLPayClaimDestinaton lnurlPayClaimDestinaton)
{
try
{
var lnurl = lnurlPayClaimDestinaton.LNURL.IsValidEmail()
? LNURL.LNURL.ExtractUriFromInternetIdentifier(lnurlPayClaimDestinaton.LNURL)
: LNURL.LNURL.Parse(lnurlPayClaimDestinaton.LNURL, out var lnurlTag);
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var rawInfo = await LNURL.LNURL.FetchInformation(lnurl, CreateClient(lnurl), timeout.Token);
if (rawInfo is LNURLPayRequest info)
return info.MinSendable.ToDecimal(LightMoneyUnit.BTC);
}
catch
{
// ignored
}
}
return Money.Satoshis(1).ToDecimal(MoneyUnit.BTC);
}
public Dictionary<PayoutState, List<(string Action, string Text)>> GetPayoutSpecificActions()

@ -225,7 +225,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
{
if (payout.State != PayoutState.AwaitingPayment)
{
_eventAggregator.Publish(new PayoutEvent(null, payout));
_eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Updated, payout));
}
}
}
@ -244,13 +244,15 @@ namespace BTCPayServer.Data.Payouts.LightningLike
var lm = new LightMoney(blob.CryptoAmount.Value, LightMoneyUnit.BTC);
if (lm > lnurlInfo.MaxSendable || lm < lnurlInfo.MinSendable)
{
payoutData.State = PayoutState.Cancelled;
return (null, new ResultVM
{
PayoutId = payoutData.Id,
Result = PayResult.Error,
Destination = blob.Destination,
Message =
$"The LNURL provided would not generate an invoice of {lm.MilliSatoshi}msats"
$"The LNURL provided would not generate an invoice of {lm.ToDecimal(LightMoneyUnit.Satoshi)} sats"
});
}

@ -44,18 +44,18 @@ namespace BTCPayServer.Data
public static PayoutBlob GetBlob(this PayoutData data, BTCPayNetworkJsonSerializerSettings serializers)
{
var result = JsonConvert.DeserializeObject<PayoutBlob>(Encoding.UTF8.GetString(data.Blob), serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode));
var result = JsonConvert.DeserializeObject<PayoutBlob>(data.Blob, serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode));
result.Metadata ??= new JObject();
return result;
}
public static void SetBlob(this PayoutData data, PayoutBlob blob, BTCPayNetworkJsonSerializerSettings serializers)
{
data.Blob = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode)));
data.Blob = JsonConvert.SerializeObject(blob, serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode)).ToString();
}
public static JObject GetProofBlobJson(this PayoutData data)
{
return data?.Proof is null ? null : JObject.Parse(Encoding.UTF8.GetString(data.Proof));
return data?.Proof is null ? null : JObject.Parse(data.Proof);
}
public static void SetProofBlob(this PayoutData data, IPayoutProof blob, JsonSerializerSettings settings)
{
@ -76,11 +76,10 @@ namespace BTCPayServer.Data
data.Proof = null;
return;
}
var bytes = Encoding.UTF8.GetBytes(blob.ToString(Formatting.None));
// We only update the property if the bytes actually changed, this prevent from hammering the DB too much
if (data.Proof is null || bytes.Length != data.Proof.Length || !bytes.SequenceEqual(data.Proof))
if (!JToken.DeepEquals(blob, data.Proof is null ? null : JObject.Parse(data.Proof)))
{
data.Proof = bytes;
data.Proof = blob.ToString(Formatting.None);
}
}

@ -10,13 +10,13 @@ namespace BTCPayServer.Data
public static PullPaymentBlob GetBlob(this PullPaymentData data)
{
var result = JsonConvert.DeserializeObject<PullPaymentBlob>(Encoding.UTF8.GetString(data.Blob));
var result = JsonConvert.DeserializeObject<PullPaymentBlob>(data.Blob);
result!.SupportedPaymentMethods = result.SupportedPaymentMethods.Where(id => id is not null).ToArray();
return result;
}
public static void SetBlob(this PullPaymentData data, PullPaymentBlob blob)
{
data.Blob = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob));
data.Blob = JsonConvert.SerializeObject(blob).ToString();
}
public static bool IsSupported(this PullPaymentData data, Payments.PaymentMethodId paymentId)

@ -1,12 +1,10 @@
using System;
using BTCPayServer.Data;
namespace BTCPayServer.Events
namespace BTCPayServer.Events;
public class UserApprovedEvent
{
public class UserApprovedEvent
{
public ApplicationUser User { get; set; }
public bool Approved { get; set; }
public Uri RequestUri { get; set; }
}
public ApplicationUser User { get; set; }
public Uri RequestUri { get; set; }
}

@ -0,0 +1,10 @@
using System;
using BTCPayServer.Data;
namespace BTCPayServer.Events;
public class UserConfirmedEmailEvent
{
public ApplicationUser User { get; set; }
public Uri RequestUri { get; set; }
}

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