Compare commits

..

178 Commits

Author SHA1 Message Date
4bf55cbfe7 Fixup changelog 2024-11-04 13:13:11 +09:00
238547bd28 bump 2024-11-04 13:10:39 +09:00
659f84ef60 Fix: Wrong manifest downloaded when installing plugin on old btcpay (Fix #6344) (#6354) 2024-11-04 13:09:56 +09:00
30d91d0bd1 Changelog v1.13.5 2024-07-17 11:25:37 +09:00
9ea1f48fa1 Fix: Kraken rate provider failing due to bid > ask 2024-07-17 11:23:41 +09:00
209f84e67b Fix: Plugin Exception Handler didn't disabled plugin if crash was detected 2024-07-17 11:23:35 +09:00
b2480ad081 Update Changelog 2024-07-15 22:00:52 +09:00
c9687622e9 Shopify: Cancel rather than close an order (#6108) 2024-07-15 22:00:39 +09:00
7711acd1e9 Shopify: Create invoice when the payment page opens (#6109) 2024-07-15 22:00:32 +09:00
a580f67991 Add changelog and bump 1.13.4 (#6102)
* 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 (#6082) 2024-07-11 21:26:40 +09:00
ff7e96b35f Handle LNURL Payouts failing due to amount restriction (#6061)
* 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 (#6087)
* 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 (#6066) 2024-07-11 10:30:00 +09:00
14113f9468 Add setup script for dev basics like users and stores (#5987)
* 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 (#6060)
* 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 (#6099) 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 (#6012)
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 (#6071) 2024-07-11 10:13:06 +09:00
6ad1c962ff Bumping LND to 0.18.1-beta (#6094) 2024-07-11 10:08:48 +09:00
eaef28ae7f Bump EF libs (#6096) 2024-07-11 10:08:33 +09:00
80f9e313bc Fix Monero local dev docker start up (#6042) 2024-07-11 09:59:00 +09:00
f2f97bb468 Greenfield: Fix missing delete app policy (#6098) 2024-07-10 16:11:52 +02:00
69f7eb11bc Receipt page fixes (#6079)
* 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 (#6078)
* TransactionLinkProviders: Don't force single item

Fixes #6077.

* 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 (#6080)
* Fix email settings on store level, when server has no email settings

Fixes #6076.

* 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 (#6084)
* Greenfield: Add missing invoice metadata to Lightning Address

Closes #6067.

* 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 #6065) 2024-07-04 18:09:03 +09:00
058a3ee96a Do not crash when refunding an invoice that has been marked settled (Fix #6003) 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 (#6048)
* 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 (#6044)
Fixes #6043 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 (#6045)
* 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 (#6047)
* 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 (#6009)
We cannot generate a proper LNURL response for free items, hence we should not show them in the print view.

Closes #5999.
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 (#6027)
* 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 (#6023) 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 (#6011) 2024-05-27 23:17:18 +09:00
8922c3de59 Add changelog for 1.13.2 (#6005) 2024-05-24 16:13:52 +09:00
fefb99dfa2 Dashboard: Add table-responsive wrapper for transactions and invoices (#6006)
Fixes #5721.
2024-05-24 14:12:10 +09:00
a3b0bbe861 Sanitizer: Allow bitcoin and lightning URI schemes (#6002)
Fixes #6001.
2024-05-24 14:12:01 +09:00
3dd562ffdc Fix tests 2024-05-23 22:22:49 +09:00
b19db7291d Bump dependencies (#5996) 2024-05-23 22:18:30 +09:00
70253cbd9f Search: Display text filters in search input (#5986)
* 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 #5984.

* Add and fix test
2024-05-23 20:22:36 +09:00
887803a328 POS: Allow overpay for articles with minimum price (#5997)
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 #5995.
2024-05-23 20:21:30 +09:00
42da90f7dc Improve data display on receipt (#5896)
Once more an improvement for the receipt, which also fixes #5882:

- 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 (#5943)
Co-authored-by: d11n <mail@dennisreimann.de>
2024-05-23 19:40:26 +09:00
9124aeb1ee Domain mapping constraint: Fix .onion case (#5948)
Fixes #5917, which is a regression introduced in #5776. 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 (#5950)
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 (#5952)
Fixes #5949.
2024-05-23 19:24:24 +09:00
e10937c253 Add refund reports (#5791)
* 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 (#5955) 2024-05-23 19:20:25 +09:00
600bbb9ce0 Theme docs link fix (#5972)
We recently removed the section the anchor links to and we'll remove the links entirely in #5947.
2024-05-23 19:19:12 +09:00
fe9e5eb9c9 Fix: Some valid taproot PSBT couldn't parsed and show better error message (Fix #5715) (#5993) 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 (#5931)
Fixes #5913.
2024-04-15 22:06:00 +09:00
b92ff7c27b Bump CLightning (#5923) 2024-04-15 18:22:51 +09:00
d24761a498 update csv export to include full date and time in 12 hour format (#5922)
* 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 #5901) 2024-04-15 11:38:56 +09:00
172dd507bd Small payment request fixes (#5926)
* 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 (#5909)
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 (#5885) 2024-04-09 11:12:38 +09:00
6ae474d214 Adding Tether as BTCPay Server Foundation Supporter (#5891)
* 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 (#5879) 2024-03-28 22:40:18 +09:00
14f8c73b08 POS: Increase size of quantity buttons (#5877)
Closes #5873.
2024-03-28 09:01:56 +09:00
529075f64c Make "Employee" default role on store settings (#5874)
* Refactoring: Use property rather than injecting StoreRepository

* Update info text

* Make "Employee" default role on store settings

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

Closes #5866.

* Obfuscate contact email on public pages

Closes #5870.

* Fix
2024-03-27 19:19:39 +09:00
83028b9b73 Adding introduction, Authentication and Usage examples sections to the API docs. (#5858) 2024-03-24 00:02:01 +09:00
1fe766cb16 Keypad: Fix images (#5857) 2024-03-22 15:16:59 +01:00
6b45eb0d3d Do not throw when local node is not synced and using external ln node (#5859)
* 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 (#5840) 2024-03-21 21:39:06 +09:00
88a1d83323 Support bbqr psbts (#5852)
* Support bbqr psbts

https://bbqr.org/ @nvk

* add js test for bbqr
2024-03-21 10:30:23 +01:00
e21a8df0f3 smaller printed receipts (#5856) 2024-03-21 17:30:34 +09:00
93f37b506b UI: Improve Create First Store view (#5854)
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 (#5844)
* 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 (#5849)
Closes #5847.
2024-03-19 14:59:26 +01:00
09dbe44bca Onboarding: Invite new users on store level (#5719)
* 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 #5726.

* 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 (#5756)
* 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 (#5842)
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 (#5769)
* 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 (#5800) 2024-03-14 11:13:26 +01:00
9b5c8a8254 POS: Add item list to keypad (#5814)
* 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 (#5613)
* 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 (#5807)
* (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 (#5781) 2024-03-14 10:29:14 +01:00
e497903bf4 Support Admin being able to view stores (#5782)
* Support Admin being able to view stores

* fix null check

* Delete obsolete empty view

* Add test

* Apply CanViewStoreSettings policy changes

Taken from #5719

* 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 (#5825)
* 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 #5831) 2024-03-13 22:29:39 +09:00
22c6468a5d Wallet: Label filter dropdown (#5802)
Uses a dropdown component for the label filter, which also work on mobile. Closes #5722.
2024-03-12 10:48:37 +01:00
a60072a431 do not have report name conflict with old plugin (#5826)
* do not have report name conflict with old plugin

* tryadd instead of add

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

Successor of #5762 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 (#5812) 2024-03-05 16:10:54 +09:00
2db1434929 Fix currency-api link (#5803) 2024-03-04 10:10:30 +09:00
a171671fe5 Update .NET 8.0 in vscode launch.json (#5805) 2024-03-01 22:07:34 +01:00
9160a1d71e Store Logo: Remove restriction of square dimension (#5738)
As discussed on #5718, 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 (#5796)
* Lightning Setup: Fix missing headline

Fixes #5795.

* Lightning Setup: Fix tab switching UI glitch

Fixes #5778.
2024-02-29 06:56:34 +01:00
e43b4ed540 Onboarding: Invite new users (#5714)
* 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 (#5790)
* 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 #5610 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 (#5735)
* 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 (#5745)
* Admin overview of the stores on the instance

POC/Draft for #5674.

* 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 (#5686)
* 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 (#5780)
* 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 (#5718)
* Server Settings: Customize instance name and add contact URL

- The custom instance name would improve #5563
- Added contact URL closes #4806

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

* Remove unused sanitizer from apps

* Allow mailto: links

Fixes #5728.
2024-02-21 20:53:24 +01:00
354338180b Store: Move support URL to Checkout Appearance and improve wording (#5717)
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 (#5750)
* Lightning: Replace user info in server URL when logging

Fixes #5747.

* Fix empty user info case
2024-02-21 14:45:05 +01:00
f72a6df55a Add legacy report (#5740) 2024-02-21 14:44:49 +01:00
9c95b98f3a Policies: Cleanup and improvements (#5731)
* 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. (#5772) 2024-02-21 14:42:15 +01:00
04037b3d2d Crowdfund : Add Buyer information / Additional information(forms) like POS (#5659)
* 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 (#5775)
Closes #5773.

- 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 (#5760)
Fixes #5754.
2024-02-21 13:34:47 +01:00
33d3a25928 Apps: Don't redirect .onion requests to canonical domain (#5776)
Fixes #5729.
2024-02-21 13:34:12 +01:00
c2acff81c6 Fix: Labels wouldn't be properly applied to some wallet's transactions (#5770) 2024-02-20 18:42:38 +09:00
214d4b0c3f Support 16mb psbts. Potentially fixes #5768 2024-02-19 14:14:41 +01:00
bd4cf61c2b potentially fix #5764 2024-02-19 13:01:08 +01:00
b592ee2fed Bumping LND to 0.17.4-beta (#5739)
* 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 (#5752) 2024-02-16 09:36:41 +01:00
335f345ce3 Update GeneralSettings.cshtml (#5748)
Removed trailing data
2024-02-13 09:48:16 +01:00
b7be93c569 Update NTag424 lib 2024-02-08 19:12:14 +09:00
cd01a7b727 Improve performance of payout db queries 2024-02-08 16:44:03 +09:00
b96e73a002 Fix: Payouts state could turn cancelled even if payment was successful 2024-02-08 16:32:41 +09:00
0bf22ddf29 Do not require user approval by default (#5733)
As discussed on Mattermost.
2024-02-06 17:04:18 +09:00
1c4dc382a8 Merge pull request #5683 from pavlenex/release-cycles-doc
Create RELEASE-CYCLES.md
2024-02-05 19:42:15 +05:00
71c5566f2b Dashboard: Tooltip for balance on a particular day (#5650)
Closes #5617.
2024-02-02 11:29:35 +01:00
6621859567 remove decimals for Colombian (COP) and Argentina's Peso (ARS) (#5710)
* remove decimals for Colombian (COP) and Argentina's Peso (ARS)

* remove js currency hardcoding

* Fixes removal of columbia and argentina's peso

* Refactor

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2024-02-02 17:16:13 +09:00
6437967e60 Fix: Closing Balance in Dashboard was showing incorrect value (#5716) 2024-02-01 15:13:05 +09:00
c5a926c50c Fix Kraken rate for LTC 2024-02-01 14:45:59 +09:00
85ab691b68 bump version 2024-02-01 14:17:14 +09:00
4d3e0ab599 Changelog 2024-02-01 10:13:18 +09:00
02663a149e Fix Kraken API 2024-02-01 10:09:32 +09:00
a8fdc4798d Remove randomize RBF from wallet UI advanced settings (#5709)
* Remove randomize RBF from wallet UI advanced settings

* remove support RBF and allow bump fee from wallet send model

* update psbt RBF
2024-01-31 21:04:19 +09:00
6290b0f3bf Admins can approve registered users (#5647)
* Users list: Cleanups

* Policies: Flip registration settings

* Policies: Add RequireUserApproval setting

* Add approval to user

* Require approval on login and for API key

* API handling

* AccountController cleanups

* Test fix

* Apply suggestions from code review

Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>

* Add missing imports

* Communicate login requirements to user on account creation

* Add login requirements to basic auth handler

* Cleanups and test fix

* Encapsulate approval logic in user service and log approval changes

* Send follow up "Account approved" email

Closes #5656.

* Add notification for admins

* Fix creating a user via the admin view

* Update list: Unify flags into status column, add approve action

* Adjust "Resend email" wording

* Incorporate feedback from code review

* Remove duplicate test server policy reset

---------

Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
2024-01-31 14:45:54 +09:00
411e0334d0 Bitnob rate provider (#5705)
* Bitnob rate provider

* Add Bitnob as recommended exchange for NGN
2024-01-30 10:18:42 +09:00
b174977bc7 Store Email Settings: Improve configuration (#5629)
* Store Email Settings: Improve configuration

This works with the existing settings and provides better guidance about the different store email cases. Closes #5623.

* Split email and notification settings
2024-01-26 10:28:50 +01:00
2111b67e2c Update changelog 2024-01-25 21:03:27 +09:00
b96cfcd14d Apps: Allow authenticated, non-owner users permissioned access (#5702)
Fixes #5698. Before this, the app lookup was constrained by the user having at least `CanModifyStoreSettings` permissions. This changes it to require the user being associated with a store, leaving the fine-grained authorization checks up to the individual actions.
2024-01-25 21:00:33 +09:00
086f713752 Wizard UI: Constrain navigation width (#5697)
This way the back and close buttons stay within the regular container size on and don't stick to the left and right end on wide screens.

Closes #5693.
2024-01-25 16:38:05 +09:00
fd67e09cf0 In Wallet Send, label were not applied to transactions (#5700) 2024-01-25 16:37:49 +09:00
6f4ca47532 Add documentation for wallet export on sparrow 2024-01-25 16:37:15 +09:00
f97f23c8a5 Do not dispose connections created by EF 2024-01-25 10:45:02 +09:00
b62985faf4 Update changelog 2024-01-24 22:55:23 +09:00
09c761aa31 Fix: Sometimes importing a wallet file from Electrum would fail 2024-01-24 22:53:40 +09:00
8089a938f3 Guest role: Fix redirect after store creation (#5689)
This ensures that guests land on the invoices list, which tehy are allowed to see — rather than the dashboard, which they don't have permissions for.

Fixes #5688.
2024-01-24 11:34:16 +01:00
35b3fef7c5 Fix wallet import (#5695)
* Fix wallet import

* Improve error message for import of wallet file
2024-01-24 17:49:15 +09:00
f31aa43c6a Wallet file parsing: Add Wasabi test case and re-add Electrum distinction (#5694)
* Extend tests, add Wasabi file

* Re-add Electrum distinction

* Specter: Fix indentation

* Cleanups
2024-01-24 09:28:22 +09:00
b03f8db06b Refactor wallet file parsing (Fix: #5690) (#5692) 2024-01-23 21:33:45 +09:00
27e70a169e Do not show warning about browser compatibility to vault on confirm address 2024-01-23 21:30:29 +09:00
6a1d17dda2 Remove ESC as a supporter (#5685)
Closes #5684.
2024-01-22 09:00:10 +09:00
95bf60c252 Create RELEASE-CYCLES.md
This PR adds documentation around release cycles in BTCPay and tends to outline processes and ensures there's documented structure on roles and responsibilities. Feedback welcome.
2024-01-20 13:44:39 +01:00
31bc6dd48c More tests on interpolation 2024-01-20 12:21:58 +09:00
f9a43b537f Remove disclaimer for lightning being in experimentation 2023-12-24 11:12:51 +09:00
350 changed files with 10459 additions and 4451 deletions

1
.gitignore vendored
View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -41,6 +41,14 @@ namespace BTCPayServer.Client
return response.IsSuccessStatusCode;
}
public virtual async Task<bool> ApproveUser(string idOrEmail, bool approved, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/{idOrEmail}/approve", null,
new ApproveUserRequest { Approved = approved }, HttpMethod.Post), token);
await HandleResponse(response);
return response.IsSuccessStatusCode;
}
public virtual async Task<ApplicationUserData[]> GetUsers(CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/", null, HttpMethod.Get), token);

View File

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

View File

@ -25,6 +25,16 @@ namespace BTCPayServer.Client.Models
/// </summary>
public bool RequiresEmailConfirmation { get; set; }
/// <summary>
/// Whether the user was approved by an admin
/// </summary>
public bool Approved { get; set; }
/// <summary>
/// whether the user needed approval on account creation
/// </summary>
public bool RequiresApproval { get; set; }
/// <summary>
/// the roles of the user
/// </summary>

View File

@ -0,0 +1,6 @@
namespace BTCPayServer.Client;
public class ApproveUserRequest
{
public bool Approved { get; set; }
}

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ using System.Text;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models
{
@ -14,11 +15,15 @@ namespace BTCPayServer.Client.Models
}
public class RegisterBoltcardRequest
{
[JsonProperty("LNURLW")]
public string LNURLW { get; set; }
[JsonConverter(typeof(HexJsonConverter))]
[JsonProperty("UID")]
public byte[] UID { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public OnExistingBehavior? OnExisting { get; set; }
[JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; } = new Dictionary<string, JToken>();
}
public class RegisterBoltcardResponse
{

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,8 @@ namespace BTCPayServer.Data
public class ApplicationUser : IdentityUser, IHasBlob<UserBlob>
{
public bool RequiresEmailConfirmation { get; set; }
public bool RequiresApproval { get; set; }
public bool Approved { get; set; }
public List<StoredFile> StoredFiles { get; set; }
[Obsolete("U2F support has been replace with FIDO2")]
public List<U2FDevice> U2FDevices { get; set; }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 =>
{
@ -112,6 +112,9 @@ namespace BTCPayServer.Migrations
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<bool>("Approved")
.HasColumnType("INTEGER");
b.Property<byte[]>("Blob")
.HasColumnType("BLOB");
@ -158,6 +161,9 @@ namespace BTCPayServer.Migrations
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("RequiresApproval")
.HasColumnType("INTEGER");
b.Property<bool>("RequiresEmailConfirmation")
.HasColumnType("INTEGER");
@ -593,7 +599,7 @@ namespace BTCPayServer.Migrations
.HasColumnType("TEXT");
b.Property<byte[]>("Blob")
.HasColumnType("BLOB");
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Date")
.HasColumnType("TEXT");
@ -607,7 +613,7 @@ namespace BTCPayServer.Migrations
.HasColumnType("TEXT");
b.Property<byte[]>("Proof")
.HasColumnType("BLOB");
.HasColumnType("TEXT");
b.Property<string>("PullPaymentDataId")
.HasColumnType("TEXT");
@ -698,7 +704,7 @@ namespace BTCPayServer.Migrations
.HasColumnType("INTEGER");
b.Property<byte[]>("Blob")
.HasColumnType("BLOB");
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("EndDate")
.HasColumnType("TEXT");

View File

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

View File

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

View File

@ -1,4 +1,4 @@
[
[
{
"name":"Afghan Afghani",
"code":"AFN",
@ -58,7 +58,7 @@
{
"name":"Argentine Peso",
"code":"ARS",
"divisibility":2,
"divisibility":0,
"symbol":null,
"crypto":false
},
@ -289,7 +289,7 @@
{
"name":"Colombian Peso",
"code":"COP",
"divisibility":2,
"divisibility":0,
"symbol":null,
"crypto":false
},

View File

@ -77,7 +77,15 @@ namespace BTCPayServer.Services.Rates
continue;
try
{
_CurrencyProviders.TryAdd(new RegionInfo(culture.LCID).ISOCurrencySymbol, culture);
var symbol = new RegionInfo(culture.LCID).ISOCurrencySymbol;
var c = symbol switch
{
// ARS and COP are officially 2 digits, but due to depreciation,
// nobody really use those anymore. (See https://github.com/btcpayserver/btcpayserver/issues/5708)
"ARS" or "COP" => ModifyCurrencyDecimalDigit(culture, 0),
_ => culture
};
_CurrencyProviders.TryAdd(symbol, c);
}
catch { }
}
@ -91,6 +99,15 @@ namespace BTCPayServer.Services.Rates
}
}
private CultureInfo ModifyCurrencyDecimalDigit(CultureInfo culture, int decimals)
{
var modifiedCulture = new CultureInfo(culture.Name);
NumberFormatInfo modifiedNumberFormat = (NumberFormatInfo)modifiedCulture.NumberFormat.Clone();
modifiedNumberFormat.CurrencyDecimalDigits = decimals;
modifiedCulture.NumberFormat = modifiedNumberFormat;
return modifiedCulture;
}
private void AddCurrency(Dictionary<string, IFormatProvider> currencyProviders, string code, int divisibility, string symbol)
{
var culture = new CultureInfo("en-US");

View File

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Services.Rates;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Rating.Providers
{
public class BitnobRateProvider : IRateProvider
{
private readonly HttpClient _httpClient;
public BitnobRateProvider(HttpClient httpClient)
{
_httpClient = httpClient ?? new HttpClient();
}
public RateSourceInfo RateSourceInfo => new("bitnob", "Bitnob", "https://api.bitnob.co/api/v1/rates/bitcoin/price");
public async Task<PairRate[]> GetRatesAsync(CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync("https://api.bitnob.co/api/v1/rates/bitcoin/price", cancellationToken);
JObject jobj = await response.Content.ReadAsAsync<JObject>(cancellationToken);
var dataObject = jobj["data"] as JObject;
if (dataObject == null)
{
return Array.Empty<PairRate>();
}
var pairRates = new List<PairRate>();
foreach (var property in dataObject.Properties())
{
string[] parts = property.Name.Split('_');
decimal value = property.Value.Value<decimal>();
pairRates.Add(new PairRate(new CurrencyPair("BTC", parts[1]), new BidAsk(value)));
}
return pairRates.ToArray();
}
}
}

View File

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

View File

@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Rating;
using ExchangeSharp;
using Microsoft.CodeAnalysis;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@ -16,7 +17,7 @@ namespace BTCPayServer.Services.Rates
// Make sure that only one request is sent to kraken in general
public class KrakenExchangeRateProvider : IRateProvider
{
public RateSourceInfo RateSourceInfo => new("kraken", "Kraken", "https://api.kraken.com/0/public/Ticker?pair=ATOMETH,ATOMEUR,ATOMUSD,ATOMXBT,BATETH,BATEUR,BATUSD,BATXBT,BCHEUR,BCHUSD,BCHXBT,DAIEUR,DAIUSD,DAIUSDT,DASHEUR,DASHUSD,DASHXBT,EOSETH,EOSXBT,ETHCHF,ETHDAI,ETHUSDC,ETHUSDT,GNOETH,GNOXBT,ICXETH,ICXEUR,ICXUSD,ICXXBT,LINKETH,LINKEUR,LINKUSD,LINKXBT,LSKETH,LSKEUR,LSKUSD,LSKXBT,NANOETH,NANOEUR,NANOUSD,NANOXBT,OMGETH,OMGEUR,OMGUSD,OMGXBT,PAXGETH,PAXGEUR,PAXGUSD,PAXGXBT,SCETH,SCEUR,SCUSD,SCXBT,USDCEUR,USDCUSD,USDCUSDT,USDTCAD,USDTEUR,USDTGBP,USDTZUSD,WAVESETH,WAVESEUR,WAVESUSD,WAVESXBT,XBTCHF,XBTDAI,XBTUSDC,XBTUSDT,XDGEUR,XDGUSD,XETCXETH,XETCXXBT,XETCZEUR,XETCZUSD,XETHXXBT,XETHZCAD,XETHZEUR,XETHZGBP,XETHZJPY,XETHZUSD,XLTCXXBT,XLTCZEUR,XLTCZUSD,XMLNXETH,XMLNXXBT,XMLNZEUR,XMLNZUSD,XREPXETH,XREPXXBT,XREPZEUR,XXBTZCAD,XXBTZEUR,XXBTZGBP,XXBTZJPY,XXBTZUSD,XXDGXXBT,XXLMXXBT,XXMRXXBT,XXMRZEUR,XXMRZUSD,XXRPXXBT,XXRPZEUR,XXRPZUSD,XZECXXBT,XZECZEUR,XZECZUSD");
public RateSourceInfo RateSourceInfo => new("kraken", "Kraken", "https://api.kraken.com/0/public/Ticker");
public HttpClient HttpClient
{
get
@ -31,39 +32,6 @@ namespace BTCPayServer.Services.Rates
HttpClient _LocalClient;
static readonly HttpClient _Client = new HttpClient();
// ExchangeSymbolToGlobalSymbol throws exception which would kill perf
readonly ConcurrentDictionary<string, string> notFoundSymbols = new ConcurrentDictionary<string, string>(new Dictionary<string, string>()
{
{"ADAXBT","ADAXBT"},
{ "BSVUSD","BSVUSD"},
{ "QTUMEUR","QTUMEUR"},
{ "QTUMXBT","QTUMXBT"},
{ "EOSUSD","EOSUSD"},
{ "XTZUSD","XTZUSD"},
{ "XREPZUSD","XREPZUSD"},
{ "ADAEUR","ADAEUR"},
{ "ADAUSD","ADAUSD"},
{ "GNOEUR","GNOEUR"},
{ "XTZETH","XTZETH"},
{ "XXRPZJPY","XXRPZJPY"},
{ "XXRPZCAD","XXRPZCAD"},
{ "XTZEUR","XTZEUR"},
{ "QTUMETH","QTUMETH"},
{ "XXLMZUSD","XXLMZUSD"},
{ "QTUMCAD","QTUMCAD"},
{ "QTUMUSD","QTUMUSD"},
{ "XTZXBT","XTZXBT"},
{ "GNOUSD","GNOUSD"},
{ "ADAETH","ADAETH"},
{ "ADACAD","ADACAD"},
{ "XTZCAD","XTZCAD"},
{ "BSVEUR","BSVEUR"},
{ "XZECZJPY","XZECZJPY"},
{ "XXLMZEUR","XXLMZEUR"},
{"EOSEUR","EOSEUR"},
{"BSVXBT","BSVXBT"}
});
string[] _Symbols = Array.Empty<string>();
DateTimeOffset? _LastSymbolUpdate = null;
readonly Dictionary<string, string> _TickerMapping = new Dictionary<string, string>()
@ -76,47 +44,57 @@ namespace BTCPayServer.Services.Rates
{ "ZEUR", "EUR" },
{ "ZJPY", "JPY" },
{ "ZCAD", "CAD" },
{ "ZGBP", "GBP" }
{ "ZGBP", "GBP" },
{ "XXMR", "XMR" },
{ "XETH", "ETH" },
{ "USDC", "USDC" }, // On A=A purpose
{ "XZEC", "ZEC" },
{ "XLTC", "LTC" },
{ "XXRP", "XRP" },
};
string Normalize(string ticker)
{
_TickerMapping.TryGetValue(ticker, out var normalized);
return normalized ?? ticker;
}
readonly ConcurrentDictionary<string, CurrencyPair> CachedCurrencyPairs = new ConcurrentDictionary<string, CurrencyPair>();
private CurrencyPair GetCurrencyPair(string symbol)
{
if (CachedCurrencyPairs.TryGetValue(symbol, out var pair))
return pair;
var found = _TickerMapping.Where(t => symbol.StartsWith(t.Key, StringComparison.OrdinalIgnoreCase))
.Select(t => new { KrakenTicker = t.Key, PayTicker = t.Value }).FirstOrDefault();
if (found is not null)
{
pair = new CurrencyPair(found.PayTicker, Normalize(symbol.Substring(found.KrakenTicker.Length)));
}
if (pair is null)
{
found = _TickerMapping.Where(t => symbol.EndsWith(t.Key, StringComparison.OrdinalIgnoreCase))
.Select(t => new { KrakenTicker = t.Key, PayTicker = t.Value }).FirstOrDefault();
if (found is not null)
pair = new CurrencyPair(Normalize(symbol.Substring(0, symbol.Length - found.KrakenTicker.Length)), found.PayTicker);
}
if (pair is null)
CurrencyPair.TryParse(symbol, out pair);
CachedCurrencyPairs.TryAdd(symbol, pair);
return pair;
}
public async Task<PairRate[]> GetRatesAsync(CancellationToken cancellationToken)
{
var result = new List<PairRate>();
var symbols = await GetSymbolsAsync(cancellationToken);
var helper = (ExchangeKrakenAPI)await ExchangeAPI.GetExchangeAPIAsync<ExchangeKrakenAPI>();
var normalizedPairsList = symbols.Where(s => !notFoundSymbols.ContainsKey(s)).Select(s => helper.NormalizeMarketSymbol(s)).ToList();
var csvPairsList = string.Join(",", normalizedPairsList);
JToken apiTickers = await MakeJsonRequestAsync<JToken>("/0/public/Ticker", null, new Dictionary<string, object> { { "pair", csvPairsList } }, cancellationToken: cancellationToken);
JToken apiTickers = await MakeJsonRequestAsync<JToken>("/0/public/Ticker", null, null, cancellationToken: cancellationToken);
foreach (string symbol in symbols)
{
var ticker = ConvertToExchangeTicker(symbol, apiTickers[symbol]);
if (ticker != null)
{
try
{
string global = null;
var mapped1 = _TickerMapping.Where(t => symbol.StartsWith(t.Key, StringComparison.OrdinalIgnoreCase))
.Select(t => new { KrakenTicker = t.Key, PayTicker = t.Value }).SingleOrDefault();
if (mapped1 != null)
{
var p2 = symbol.Substring(mapped1.KrakenTicker.Length);
if (_TickerMapping.TryGetValue(p2, out var mapped2))
p2 = mapped2;
global = $"{mapped1.PayTicker}_{p2}";
}
else
{
global = await helper.ExchangeMarketSymbolToGlobalMarketSymbolAsync(symbol);
}
if (CurrencyPair.TryParse(global, out var pair))
result.Add(new PairRate(pair, new BidAsk(ticker.Bid, ticker.Ask)));
else
notFoundSymbols.TryAdd(symbol, symbol);
}
catch (ArgumentException)
{
notFoundSymbols.TryAdd(symbol, symbol);
}
var pair = GetCurrencyPair(symbol);
if (pair is not null && ticker.Bid <= ticker.Ask)
result.Add(new PairRate(pair, new BidAsk(ticker.Bid, ticker.Ask)));
}
}
return result.ToArray();

View File

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

View File

@ -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="119.0.6045.10500" />
<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>

View File

@ -245,6 +245,9 @@ namespace BTCPayServer.Tests
rateProvider.Providers.Add("kraken", kraken);
}
// reset test server policies
var settings = GetService<SettingsRepository>();
await settings.UpdateSetting(new PoliciesSettings { LockSubscription = false, RequiresUserApproval = false });
TestLogs.LogInformation("Waiting site is operational...");
await WaitSiteIsOperational();

View File

@ -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,11 +364,13 @@ namespace BTCPayServer.Tests
expirySeconds.Clear();
expirySeconds.SendKeys("5");
s.Driver.FindElement(By.Id("Expire")).Click();
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);
TestUtils.Eventually(() =>
{
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);
});
// Configure countdown timer
s.GoToHome();
@ -379,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);
@ -387,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();
@ -457,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());

View File

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

View File

@ -175,6 +175,11 @@ namespace BTCPayServer.Tests
([(0, 0m), (500, 50m), (1000, 100m)], 100, 10m),
([(0, 0m), (100, 100m)], 80, 80m),
([(0, 0m), (100, 100m)], 25, 25m),
([(0, 0m), (25, 25m), (50, 50m), (100, 100m), (110, 120m)], 75, 75m),
([(0, 0m), (25, 0m), (50, 50m), (100, 100m), (110, 0m)], 75, 75m),
([(0, 0m), (25, 0m), (50, 50m), (100, 100m), (110, 0m)], 50, 50m),
([(0, 0m), (25, 0m), (50, 50m), (100, 100m), (110, 0m)], 100, 100m),
([(0, 0m), (25, 0m), (50, 50m), (100, 100m), (110, 0m)], 102, 80m),
};
foreach (var t in testData)
{
@ -784,13 +789,15 @@ namespace BTCPayServer.Tests
(0.0005m, "0.0005 USD", "USD"), (0.001m, "0.001 USD", "USD"), (0.01m, "0.01 USD", "USD"),
(0.1m, "0.10 USD", "USD"), (0.1m, "0,10 EUR", "EUR"), (1000m, "1,000 JPY", "JPY"),
(1000.0001m, "1,000.00 INR", "INR"),
(0.0m, "0.00 USD", "USD")
(0.0m, "0.00 USD", "USD"), (1m, "1 COP", "COP"), (1m, "1 ARS", "ARS")
})
{
var actual = displayFormatter.Currency(test.Item1, test.Item3);
actual = actual.Replace("¥", "¥"); // Hack so JPY test pass on linux as well
Assert.Equal(test.Item2, actual);
}
Assert.Equal(0, CurrencyNameTable.Instance.GetNumberFormatInfo("ARS").CurrencyDecimalDigits);
Assert.Equal(0, CurrencyNameTable.Instance.GetNumberFormatInfo("COP").CurrencyDecimalDigits);
}
[Fact]
@ -850,8 +857,7 @@ namespace BTCPayServer.Tests
// xpub
var xpub = "xpub661MyMwAqRbcGVBsTGeNZN6QGVHmMHLdSA4FteGsRrEriu4pnVZMZWnruFFFXkMnyoBjyHndD3Qwcfz4MPzBUxjSevweNFQx7SAYZATtcDw";
Assert.Throws<FormatException>(() => parser.Parse(xpub, false, false, true));
DerivationStrategyBase strategyBase = parser.Parse(xpub, false, false, false);
DerivationStrategyBase strategyBase = parser.Parse(xpub);
Assert.IsType<DirectDerivationStrategy>(strategyBase);
Assert.True(((DirectDerivationStrategy)strategyBase).Segwit);
Assert.Equal("tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS", strategyBase.ToString());
@ -931,9 +937,10 @@ namespace BTCPayServer.Tests
// xpub
var tpub = "tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS";
Assert.True(parsers.TryParseWalletFile(tpub, testnet, out var settings, out var error));
Assert.Null(error);
Assert.True(settings.AccountDerivation is DirectDerivationStrategy { Segwit: false });
Assert.Equal($"{tpub}-[legacy]", ((DirectDerivationStrategy)settings.AccountDerivation).ToString());
Assert.Equal("GenericFile", settings.Source);
Assert.Null(error);
// xpub with fingerprint and account
tpub = "tpubDCXK98mNrPWuoWweaoUkqwxQF5NMWpQLy7n7XJgDCpwYfoZRXGafPaVM7mYqD7UKhsbMxkN864JY2PniMkt1Uk4dNuAMnWFVqdquyvZNyca";
@ -948,6 +955,8 @@ namespace BTCPayServer.Tests
Assert.Equal(tpub, ((DirectDerivationStrategy)settings.AccountDerivation).ToString());
Assert.Equal(HDFingerprint.TryParse(fingerprint, out var hd) ? hd : default, settings.AccountKeySettings[0].RootFingerprint);
Assert.Equal(account, settings.AccountKeySettings[0].AccountKeyPath.ToString());
Assert.Equal("GenericFile", settings.Source);
Assert.Null(error);
// ColdCard
Assert.True(parsers.TryParseWalletFile(
@ -964,12 +973,15 @@ namespace BTCPayServer.Tests
settings.AccountOriginal);
Assert.Equal(root.Derive(new KeyPath("m/49'/0'/0'")).Neuter().PubKey.WitHash.ScriptPubKey.Hash.ScriptPubKey,
settings.AccountDerivation.GetDerivation().ScriptPubKey);
Assert.Equal("ElectrumFile", settings.Source);
Assert.Null(error);
// Should be legacy
Assert.True(parsers.TryParseWalletFile(
"{\"keystore\": {\"ckcc_xpub\": \"tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS\", \"xpub\": \"tpubDDWYqT3P24znfsaGX7kZcQhNc5LAjnQiKQvUCHF2jS6dsgJBRtymopEU5uGpMaR5YChjuiExZG1X2aTbqXkp82KqH5qnqwWHp6EWis9ZvKr\", \"label\": \"Coldcard Import 0x60d1af8b\", \"ckcc_xfp\": 1624354699, \"type\": \"hardware\", \"hw_type\": \"coldcard\", \"derivation\": \"m/44'/1'/0'\"}, \"wallet_type\": \"standard\", \"use_encryption\": false, \"seed_version\": 17}",
testnet, out settings, out error));
Assert.True(settings.AccountDerivation is DirectDerivationStrategy { Segwit: false });
Assert.Equal("ElectrumFile", settings.Source);
Assert.Null(error);
// Should be segwit p2sh
@ -977,6 +989,7 @@ namespace BTCPayServer.Tests
"{\"keystore\": {\"ckcc_xpub\": \"tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS\", \"xpub\": \"upub5DSddA9NoRUyJrQ4p86nsCiTSY7kLHrSxx3joEJXjHd4HPARhdXUATuk585FdWPVC2GdjsMePHb6BMDmf7c6KG4K4RPX6LVqBLtDcWpQJmh\", \"label\": \"Coldcard Import 0x60d1af8b\", \"ckcc_xfp\": 1624354699, \"type\": \"hardware\", \"hw_type\": \"coldcard\", \"derivation\": \"m/49'/1'/0'\"}, \"wallet_type\": \"standard\", \"use_encryption\": false, \"seed_version\": 17}",
testnet, out settings, out error));
Assert.True(settings.AccountDerivation is P2SHDerivationStrategy { Inner: DirectDerivationStrategy { Segwit: true } });
Assert.Equal("ElectrumFile", settings.Source);
Assert.Null(error);
// Should be segwit
@ -984,6 +997,7 @@ namespace BTCPayServer.Tests
"{\"keystore\": {\"ckcc_xpub\": \"tpubD6NzVbkrYhZ4YHNiuTdTmHRmbcPRLfqgyneZFCL1mkzkUBjXriQShxTh9HL34FK2mhieasJVk9EzJrUfkFqRNQBjiXgx3n5BhPkxKBoFmaS\", \"xpub\": \"vpub5YjYxTemJ39tFRnuAhwduyxG2tKGjoEpmvqVQRPqdYrqa6YGoeSzBtHXaJUYB19zDbXs3JjbEcVWERjQBPf9bEfUUMZNMv1QnMyHV8JPqyf\", \"label\": \"Coldcard Import 0x60d1af8b\", \"ckcc_xfp\": 1624354699, \"type\": \"hardware\", \"hw_type\": \"coldcard\", \"derivation\": \"m/84'/1'/0'\"}, \"wallet_type\": \"standard\", \"use_encryption\": false, \"seed_version\": 17}",
testnet, out settings, out error));
Assert.True(settings.AccountDerivation is DirectDerivationStrategy { Segwit: true });
Assert.Equal("ElectrumFile", settings.Source);
Assert.Null(error);
// Specter
@ -993,11 +1007,21 @@ namespace BTCPayServer.Tests
Assert.Equal(root.GetPublicKey().GetHDFingerPrint(), specter.AccountKeySettings[0].RootFingerprint);
Assert.Equal(specter.AccountKeySettings[0].RootFingerprint, hd);
Assert.Equal("49'/0'/0'", specter.AccountKeySettings[0].AccountKeyPath.ToString());
Assert.True(specter.AccountDerivation is DirectDerivationStrategy { Segwit: true });
Assert.Equal("Specter", specter.Label);
Assert.Null(error);
//BSMS BIP129, Nunchuk
// Wasabi
var wasabiJson = @"{""EncryptedSecret"": ""6PYNUAZZLS1ShkhHhm9ayiNwXPAPLN669fN5mY2WbGm1Hqc88tomqWXabU"",""ChainCode"": ""UoHIB+2mDbZSowo11TfDQbsYK6q1DrZ2H2yqQBxu6m8="",""MasterFingerprint"": ""0f215605"",""ExtPubKey"": ""xpub6DUXFa6fMrFpg7x4nEd8jBU6xDN3vkSXsVUrSbUB2dadbYaPE31czwVdv146JRStGsc2U6TywdKnGoVcP8Rtp2AZQyzXxQb7HrgmR9LrqLA"",""TaprootExtPubKey"": ""xpub6D2thLU5KwUk3axkJu1UT3yKFshCGU7TMuxhPgZMd91VvrcDwHdRwdzLk61cSHtZC6BkaipPgfFwjoDBY4m1WxyznxZLukYgM4dC6iRJVf8"",""SkipSynchronization"": true,""UseTurboSync"": true,""MinGapLimit"": 21,""AccountKeyPath"": ""84'/0'/0'"",""TaprootAccountKeyPath"": ""86'/0'/0'"",""BlockchainState"": {""Network"": ""Main"",""Height"": ""503723"",""TurboSyncHeight"": ""503723""},""PreferPsbtWorkflow"": false,""AutoCoinJoin"": true,""PlebStopThreshold"": ""0.01"",""AnonScoreTarget"": 5,""FeeRateMedianTimeFrameHours"": 0,""IsCoinjoinProfileSelected"": true,""RedCoinIsolation"": false,""ExcludedCoinsFromCoinJoin"": [],""HdPubKeys"": [{""PubKey"": ""03f88b9c3e16e40a5a9eaf8b36b9bcee7bbc93fd9eea640b541efb931ac55f7ff5"",""FullKeyPath"": ""84'/0'/0'/1/0"",""Label"": """",""KeyState"": 0},{""PubKey"": ""03e5241fc28aa556d7cb826b9a9f5ecee85287e7476746126263574a5e27fbf569"",""FullKeyPath"": ""84'/0'/0'/0/0"",""Label"": """",""KeyState"": 0}]}";
Assert.True(parsers.TryParseWalletFile(wasabiJson, mainnet, out var wasabi, out error));
Assert.Null(error);
Assert.Equal("WasabiFile", wasabi.Source);
Assert.Single(wasabi.AccountKeySettings);
Assert.Equal("84'/0'/0'", wasabi.AccountKeySettings[0].AccountKeyPath.ToString());
Assert.Equal("0f215605", wasabi.AccountKeySettings[0].RootFingerprint.ToString());
Assert.True(wasabi.AccountDerivation is DirectDerivationStrategy { Segwit: true });
// BSMS BIP129, Nunchuk
var bsms = @"BSMS 1.0
wsh(sortedmulti(1,[5c9e228d/48'/0'/0'/2']xpub6EgGHjcvovyN3nK921zAGPfuB41cJXkYRdt3tLGmiMyvbgHpss4X1eRZwShbEBb1znz2e2bCkCED87QZpin3sSYKbmCzQ9Sc7LaV98ngdeX/**,[2b0e251e/48'/0'/0'/2']xpub6DrimHB8KUSkPvmJ8Pk8RE769EdDm2VEoZ8MBz76w9QupP8Py4wexs4Pa3aRB1LUEhc9GyY6ypDWEFFRCgqeDQePcyWQfjtmintrehq3JCL/**))
/0/*,/1/*
@ -1046,6 +1070,72 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
Assert.True(passport.AccountDerivation is TaprootDerivationStrategy);
Assert.Equal("5c9e228d", passport.AccountKeySettings[0].RootFingerprint.ToString());
Assert.Equal("86'/0'/0'", passport.AccountKeySettings[0].AccountKeyPath.ToString());
//electrum
var electrumText =
"""
{
"keystore": {
"xpub": "vpub5Z14bnDNoEQeFdwZYSpVHcpzRpH99CnvSemzqTAvhjcgBTzPUVnaA5GhjgZc9J46duUprxQRUVUuqchazanXD6bLuVyarviNHBFUu6fBZNj",
"xprv": "vprv9ENJcv8RKwqMTqyhLSuBz5bEV7hpdZjisjUBuV9K8azz1vpop6xJFEDRdfDwgWBpYgUUhEVxdvpxgV3f8NircysfebnBaPu5y2dcnSDAEEw",
"type": "bip32",
"pw_hash_version": 1
},
"wallet_type": "standard",
"use_encryption": false,
"seed_type": "bip39"
}
""";
Assert.True(parsers.TryParseWalletFile(electrumText, testnet, out var electrum, out _));
Assert.Equal("ElectrumFile", electrum.Source);
electrumText =
"""
{
"keystore": {
"derivation": "m/0h",
"pw_hash_version": 1,
"root_fingerprint": "fbb5b37d",
"seed": "tiger room acoustic bracket thing film umbrella rather pepper tired vault remain",
"seed_type": "segwit",
"type": "bip32",
"xprv": "zprvAaQyp6mTAX53zY4j2BbecRNtmTq2kSEKgy2y4yK3bFPKgPJLxrMmPxzZdRkWq5XvmtH2R4ko5YmJYH2MgnVkWr32pHi4Dc5627WyML32KTW",
"xpub": "zpub6oQLDcJLztdMD29C8D8eyZKdKVfX9txB4BxZsMif9avJZBdVWPg1wmK3Uh3VxU7KXon1wm1xzvjyqmKWguYMqyjKP5f5Cho9f7uLfmRt2Br"
},
"wallet_type": "standard",
"use_encryption": false,
"seed_type": "bip39"
}
""";
Assert.True(parsers.TryParseWalletFile(electrumText, mainnet, out electrum, out _));
Assert.Equal("ElectrumFile", electrum.Source);
Assert.Equal("0'", electrum.GetSigningAccountKeySettings().AccountKeyPath.ToString());
Assert.True(electrum.AccountDerivation is DirectDerivationStrategy { Segwit: true });
Assert.Equal("fbb5b37d", electrum.GetSigningAccountKeySettings().RootFingerprint.ToString());
Assert.Equal("zpub6oQLDcJLztdMD29C8D8eyZKdKVfX9txB4BxZsMif9avJZBdVWPg1wmK3Uh3VxU7KXon1wm1xzvjyqmKWguYMqyjKP5f5Cho9f7uLfmRt2Br", electrum.AccountOriginal);
Assert.Equal(((DirectDerivationStrategy)electrum.AccountDerivation).GetExtPubKeys().First().ParentFingerprint.ToString(), electrum.GetSigningAccountKeySettings().RootFingerprint.ToString());
// Electrum with strange garbage at the end caused by the lightning support
electrumText =
"""
{
"keystore": {
"derivation": "m/0h",
"pw_hash_version": 1,
"root_fingerprint": "fbb5b37d",
"seed": "tiger room acoustic bracket thing film umbrella rather pepper tired vault remain",
"seed_type": "segwit",
"type": "bip32",
"xprv": "zprvAaQyp6mTAX53zY4j2BbecRNtmTq2kSEKgy2y4yK3bFPKgPJLxrMmPxzZdRkWq5XvmtH2R4ko5YmJYH2MgnVkWr32pHi4Dc5627WyML32KTW",
"xpub": "zpub6oQLDcJLztdMD29C8D8eyZKdKVfX9txB4BxZsMif9avJZBdVWPg1wmK3Uh3VxU7KXon1wm1xzvjyqmKWguYMqyjKP5f5Cho9f7uLfmRt2Br"
},
"wallet_type": "standard",
"use_encryption": false,
"seed_type": "bip39"
},
{"op": "remove", "path": "/channels"}
""";
Assert.True(parsers.TryParseWalletFile(electrumText, mainnet, out electrum, out _));
}
[Fact]
@ -1256,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";
@ -1926,7 +2021,7 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
// Passing electrum stuff
// Passing a native segwit from mainnet to a testnet parser, means the testnet parser will try to convert it into segwit
result = testnetParser.Parse(
"zpub6nL6PUGurpU3DfPDSZaRS6WshpbNc9ctCFFzrCn54cssnheM31SZJZUcFHKtjJJNhAueMbh6ptFMfy1aeiMQJr3RJ4DDt1hAPx7sMTKV48t", false, false, false);
"zpub6nL6PUGurpU3DfPDSZaRS6WshpbNc9ctCFFzrCn54cssnheM31SZJZUcFHKtjJJNhAueMbh6ptFMfy1aeiMQJr3RJ4DDt1hAPx7sMTKV48t");
Assert.Equal(
"tpubD93CJNkmGjLXnsBqE2zGDqfEh1Q8iJ8wueordy3SeWt1RngbbuxXCsqASuVWFywmfoCwUE1rSfNJbaH4cBNcbp8WcyZgPiiRSTazLGL8U9w",
result.ToString());
@ -1950,7 +2045,7 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
// if prefix not recognize, assume it is segwit
result = testnetParser.Parse(
"xpub661MyMwAqRbcGeVGU5e5KBcau1HHEUGf9Wr7k4FyLa8yRPNQrrVa7Ndrgg8Afbe2UYXMSL6tJBFd2JewwWASsePPLjkcJFL1tTVEs3UQ23X", false, false, false);
"xpub661MyMwAqRbcGeVGU5e5KBcau1HHEUGf9Wr7k4FyLa8yRPNQrrVa7Ndrgg8Afbe2UYXMSL6tJBFd2JewwWASsePPLjkcJFL1tTVEs3UQ23X");
Assert.Equal(
"tpubD6NzVbkrYhZ4YSg7vGdAX6wxE8NwDrmih9SR6cK7gUtsAg37w5LfFpJgviCxC6bGGT4G3uckqH5fiV9ZLN1gm5qgQLVuymzFUR5ed7U7ksu",
result.ToString());
@ -1959,13 +2054,13 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
var tpub =
"tpubD6NzVbkrYhZ4Wc65tjhmcKdWFauAo7bGLRTxvggygkNyp6SMGutJp7iociwsinU33jyNBp1J9j2hJH5yQsayfiS3LEU2ZqXodAcnaygra8o";
result = testnetParser.Parse(tpub, false, true);
result = testnetParser.Parse(tpub);
Assert.Equal(tpub, result.ToString());
var regtestParser = new DerivationSchemeParser(regtestNetworkProvider.GetNetwork<BTCPayNetwork>("BTC"));
var parsed =
regtestParser.Parse(
"xpub6DG1rMYXiQtCc6CfdLFD9CtxqhzzRh7j6Sq6EdE9abgYy3cfDRrniLLv2AdwqHL1exiLnnKR5XXcaoiiexf3Y9R6J6rxkJtqJHzNzMW9QMZ-[p2sh]", false, false, false);
"xpub6DG1rMYXiQtCc6CfdLFD9CtxqhzzRh7j6Sq6EdE9abgYy3cfDRrniLLv2AdwqHL1exiLnnKR5XXcaoiiexf3Y9R6J6rxkJtqJHzNzMW9QMZ-[p2sh]");
Assert.Equal(
"tpubDDdeNbNDRgqestPX5XEJM8ELAq6eR5cne5RPbBHHvWSSiLHNHehsrn1kGCijMnHFSsFFQMqHcdMfGzDL3pWHRasPMhcGRqZ4tFankQ3i4ok-[p2sh]",
parsed.ToString());
@ -1973,14 +2068,14 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
// Let's make sure we can't generate segwit with dogecoin
regtestParser = new DerivationSchemeParser(regtestNetworkProvider.GetNetwork<BTCPayNetwork>("DOGE"));
parsed = regtestParser.Parse(
"xpub6DG1rMYXiQtCc6CfdLFD9CtxqhzzRh7j6Sq6EdE9abgYy3cfDRrniLLv2AdwqHL1exiLnnKR5XXcaoiiexf3Y9R6J6rxkJtqJHzNzMW9QMZ-[p2sh]", false, false, false);
"xpub6DG1rMYXiQtCc6CfdLFD9CtxqhzzRh7j6Sq6EdE9abgYy3cfDRrniLLv2AdwqHL1exiLnnKR5XXcaoiiexf3Y9R6J6rxkJtqJHzNzMW9QMZ-[p2sh]");
Assert.Equal(
"tpubDDdeNbNDRgqestPX5XEJM8ELAq6eR5cne5RPbBHHvWSSiLHNHehsrn1kGCijMnHFSsFFQMqHcdMfGzDL3pWHRasPMhcGRqZ4tFankQ3i4ok-[legacy]",
parsed.ToString());
regtestParser = new DerivationSchemeParser(regtestNetworkProvider.GetNetwork<BTCPayNetwork>("DOGE"));
parsed = regtestParser.Parse(
"tpubDDdeNbNDRgqestPX5XEJM8ELAq6eR5cne5RPbBHHvWSSiLHNHehsrn1kGCijMnHFSsFFQMqHcdMfGzDL3pWHRasPMhcGRqZ4tFankQ3i4ok-[p2sh]", false, false, false);
"tpubDDdeNbNDRgqestPX5XEJM8ELAq6eR5cne5RPbBHHvWSSiLHNHehsrn1kGCijMnHFSsFFQMqHcdMfGzDL3pWHRasPMhcGRqZ4tFankQ3i4ok-[p2sh]");
Assert.Equal(
"tpubDDdeNbNDRgqestPX5XEJM8ELAq6eR5cne5RPbBHHvWSSiLHNHehsrn1kGCijMnHFSsFFQMqHcdMfGzDL3pWHRasPMhcGRqZ4tFankQ3i4ok-[legacy]",
parsed.ToString());
@ -2204,7 +2299,7 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
["derivationStrategy"] = "tpubDDLQZ1WMdy5YJAJWmRNoTJ3uQkavEPXCXnmD4eAuo9BKbzFUBbJmVHys5M3ku4Qw1C165wGpVWH55gZpHjdsCyntwNzhmCAzGejSL6rzbyf"
};
var scheme = DerivationSchemeSettings.Parse("tpubDDLQZ1WMdy5YJAJWmRNoTJ3uQkavEPXCXnmD4eAuo9BKbzFUBbJmVHys5M3ku4Qw1C165wGpVWH55gZpHjdsCyntwNzhmCAzGejSL6rzbyf", CreateNetworkProvider(ChainName.Regtest).BTC);
Assert.True(scheme.AccountDerivation is DirectDerivationStrategy { Segwit: true });
scheme.Source = "ManualDerivationScheme";
scheme.AccountOriginal = "tpubDDLQZ1WMdy5YJAJWmRNoTJ3uQkavEPXCXnmD4eAuo9BKbzFUBbJmVHys5M3ku4Qw1C165wGpVWH55gZpHjdsCyntwNzhmCAzGejSL6rzbyf";
var legacy2 = new JObject()

View File

@ -13,6 +13,7 @@ using BTCPayServer.Controllers;
using BTCPayServer.Events;
using BTCPayServer.Lightning;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.NTag424;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors;
@ -24,6 +25,7 @@ using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitpayClient;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@ -301,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 () =>
{
@ -470,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 () =>
{
@ -694,14 +716,10 @@ namespace BTCPayServer.Tests
// Try loading 1 user by email. Loading myself.
await AssertHttpError(403, async () => await badClient.GetUserByIdOrEmail(badUser.Email));
// Why is this line needed? I saw it in "CanDeleteUsersViaApi" as well. Is this part of the cleanup?
tester.Stores.Remove(adminUser.StoreId);
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanCreateUsersViaAPI()
@ -1119,6 +1137,35 @@ namespace BTCPayServer.Tests
OnExisting = OnExistingBehavior.KeepVersion
});
Assert.Equal(card2.Version, card3.Version);
var p = new byte[] { 0xc7 }.Concat(uid).Concat(new byte[8]).ToArray();
var card4 = await client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
{
OnExisting = OnExistingBehavior.KeepVersion,
LNURLW = card2.LNURLW + $"?p={Encoders.Hex.EncodeData(AESKey.Parse(card2.K1).Encrypt(p))}"
});
Assert.Equal(card2.Version, card4.Version);
Assert.Equal(card2.K4, card4.K4);
// Can't define both properties
await AssertValidationError(["LNURLW"], () => client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
{
OnExisting = OnExistingBehavior.KeepVersion,
UID = uid,
LNURLW = card2.LNURLW + $"?p={Encoders.Hex.EncodeData(AESKey.Parse(card2.K1).Encrypt(p))}"
}));
// p is malformed
await AssertValidationError(["LNURLW"], () => client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
{
OnExisting = OnExistingBehavior.KeepVersion,
UID = uid,
LNURLW = card2.LNURLW + $"?p=lol"
}));
// p is invalid
p[0] = 0;
await AssertValidationError(["LNURLW"], () => client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
{
OnExisting = OnExistingBehavior.KeepVersion,
LNURLW = card2.LNURLW + $"?p={Encoders.Hex.EncodeData(AESKey.Parse(card2.K1).Encrypt(p))}"
}));
// Test with SATS denomination values
var testSats = await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest()
{
@ -2113,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)]
@ -2291,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>());
@ -2335,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);
@ -2345,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);
@ -3410,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));
@ -3437,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();
@ -3453,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)]
@ -3571,6 +3671,78 @@ namespace BTCPayServer.Tests
await newUserBasicClient.GetCurrentUser();
}
[Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")]
public async Task ApproveUserTests()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var admin = tester.NewAccount();
await admin.GrantAccessAsync(true);
var adminClient = await admin.CreateClient(Policies.Unrestricted);
Assert.False((await adminClient.GetUserByIdOrEmail(admin.UserId)).RequiresApproval);
Assert.Empty(await adminClient.GetNotifications());
// require approval
var settings = tester.PayTester.GetService<SettingsRepository>();
await settings.UpdateSetting(new PoliciesSettings { LockSubscription = false, RequiresUserApproval = true });
// new user needs approval
var unapprovedUser = tester.NewAccount();
await unapprovedUser.GrantAccessAsync();
var unapprovedUserBasicAuthClient = await unapprovedUser.CreateClient();
await AssertAPIError("unauthenticated", async () =>
{
await unapprovedUserBasicAuthClient.GetCurrentUser();
});
var unapprovedUserApiKeyClient = await unapprovedUser.CreateClient(Policies.Unrestricted);
await AssertAPIError("unauthenticated", async () =>
{
await unapprovedUserApiKeyClient.GetCurrentUser();
});
Assert.True((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).RequiresApproval);
Assert.False((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).Approved);
Assert.Single(await adminClient.GetNotifications(false));
// approve
Assert.True(await adminClient.ApproveUser(unapprovedUser.UserId, true, CancellationToken.None));
Assert.True((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).Approved);
Assert.True((await unapprovedUserApiKeyClient.GetCurrentUser()).Approved);
Assert.True((await unapprovedUserBasicAuthClient.GetCurrentUser()).Approved);
// un-approve
Assert.True(await adminClient.ApproveUser(unapprovedUser.UserId, false, CancellationToken.None));
Assert.False((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).Approved);
await AssertAPIError("unauthenticated", async () =>
{
await unapprovedUserApiKeyClient.GetCurrentUser();
});
await AssertAPIError("unauthenticated", async () =>
{
await unapprovedUserBasicAuthClient.GetCurrentUser();
});
// reset policies to not require approval
await settings.UpdateSetting(new PoliciesSettings { LockSubscription = false, RequiresUserApproval = false });
// new user does not need approval
var newUser = tester.NewAccount();
await newUser.GrantAccessAsync();
var newUserBasicAuthClient = await newUser.CreateClient();
var newUserApiKeyClient = await newUser.CreateClient(Policies.Unrestricted);
Assert.False((await newUserApiKeyClient.GetCurrentUser()).RequiresApproval);
Assert.False((await newUserApiKeyClient.GetCurrentUser()).Approved);
Assert.False((await newUserBasicAuthClient.GetCurrentUser()).RequiresApproval);
Assert.False((await newUserBasicAuthClient.GetCurrentUser()).Approved);
Assert.Single(await adminClient.GetNotifications(false));
// try unapproving user which does not have the RequiresApproval flag
await AssertAPIError("invalid-state", async () =>
{
await adminClient.ApproveUser(newUser.UserId, false, CancellationToken.None);
});
}
[Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")]
[Trait("Lightning", "Lightning")]
@ -3808,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)
@ -3840,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

View File

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

View File

@ -8,6 +8,7 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
using BTCPayServer.Lightning.CLightning;
using BTCPayServer.Services;
using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server;
using BTCPayServer.Views.Stores;
@ -76,6 +77,7 @@ namespace BTCPayServer.Tests
// A bit less than test timeout
TimeSpan.FromSeconds(50));
}
ServerUri = Server.PayTester.ServerUri;
Driver.Manage().Window.Maximize();
@ -88,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();
@ -96,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();
}
}
@ -406,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);
@ -643,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

View File

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

View File

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

View File

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

View File

@ -191,6 +191,12 @@ namespace BTCPayServer.Tests
// Ripio keeps changing their pair, so anything is fine...
Assert.NotEmpty(exchangeRates.ByExchange[name]);
}
else if (name == "bitnob")
{
Assert.Contains(exchangeRates.ByExchange[name],
e => e.CurrencyPair == new CurrencyPair("BTC", "NGN") &&
e.BidAsk.Bid > 1.0m); // 1 BTC will always be more than 1 NGN
}
else if (name == "cryptomarket")
{
Assert.Contains(exchangeRates.ByExchange[name],
@ -266,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>())
@ -458,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();
@ -488,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)

View File

@ -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);
@ -2325,17 +2497,21 @@ namespace BTCPayServer.Tests
using var tester = CreateServerTester(newDb: true);
await tester.StartAsync();
var f = tester.PayTester.GetService<ApplicationDbContextFactory>();
const string id = "BTCPayServer.Services.PoliciesSettings";
using (var ctx = f.CreateContext())
{
var setting = new SettingData() { Id = "BTCPayServer.Services.PoliciesSettings" };
setting.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();
// remove existing policies setting
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, \"DisableNonAdminCreateUserApi\": false, \"AllowHotWalletRPCImportForAll\": false, \"AllowLightningInternalNodeForAll\": false, \"DisableStoresToUseServerEmailSettings\": false}").ToString() };
ctx.Settings.Add(setting);
await ctx.SaveChangesAsync();
}
await RestartMigration(tester);
using (var ctx = f.CreateContext())
{
var setting = await ctx.Settings.FirstOrDefaultAsync(c => c.Id == "BTCPayServer.Services.PoliciesSettings");
var setting = await ctx.Settings.FirstOrDefaultAsync(c => c.Id == id);
var o = JObject.Parse(setting.Value);
Assert.Equal("Crowdfund", o["RootAppType"].Value<string>());
o = (JObject)((JArray)o["DomainToAppMapping"])[0];
@ -2606,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>();
@ -2631,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",
@ -2809,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();
@ -2910,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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -46,13 +46,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BTCPayServer.NTag424" Version="1.0.20" />
<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.3" />
<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" />
@ -77,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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,19 +54,23 @@ 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())
{
<li><hr class="dropdown-divider"></li>
}
<li ><a asp-controller="UIUserStores" asp-action="CreateStore" class="dropdown-item @ViewData.IsActivePage(StoreNavPages.Create)" id="StoreSelectorCreate">Create Store</a></li>
<li><a asp-controller="UIUserStores" asp-action="CreateStore" class="dropdown-item @ViewData.IsActivePage(StoreNavPages.Create)" id="StoreSelectorCreate">Create Store</a></li>
@if (Model.ArchivedCount > 0)
{
<li><hr class="dropdown-divider"></li>
<li><a asp-controller="UIUserStores" asp-action="ListStores" asp-route-archived="true" class="dropdown-item @ViewData.IsActivePage(StoreNavPages.Index)" id="StoreSelectorArchived">@Model.ArchivedCount Archived Store@(Model.ArchivedCount == 1 ? "" : "s")</a></li>
}
@*
<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>

View File

@ -64,13 +64,14 @@
const id = `StoreWalletBalance-${storeId}`;
const baseUrl = @Safe.Json(Url.Action("WalletHistogram", "UIWallets", new { walletId = Model.WalletId, type = WalletHistogramType.Week }));
const valueTransform = value => rate
? DashboardUtils.displayDefaultCurrency(value, rate, defaultCurrency, divisibility).toString()
: value
const chartOpts = {
fullWidth: true,
showArea: true,
axisY: {
labelInterpolationFnc: value => rate
? DashboardUtils.displayDefaultCurrency(value, rate, defaultCurrency, divisibility).toString()
: value
labelInterpolationFnc: valueTransform
}
};
@ -80,16 +81,22 @@
document.querySelectorAll(`#${id} .currency`).forEach(c => c.innerText = currency)
document.querySelectorAll(`#${id} [data-balance]`).forEach(c => {
const value = Number.parseFloat(c.dataset.balance);
c.innerText = rate
? DashboardUtils.displayDefaultCurrency(value, rate, currency, divisibility)
: value
c.innerText = valueTransform(value)
});
if (!series) return;
const min = Math.min(...series);
const max = Math.max(...series);
const low = Math.max(min - ((max - min) / 5), 0);
const renderOpts = Object.assign({}, chartOpts, { low });
const tooltip = Chartist.plugins.tooltip2({
template: '{{value}}',
offset: {
x: 0,
y: -16
},
valueTransformFunction: valueTransform
})
const renderOpts = Object.assign({}, chartOpts, { low, plugins: [tooltip] });
const chart = new Chartist.Line(`#${id} .ct-chart`, {
labels,
series: [series]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics.CodeAnalysis;
using System.IO.IsolatedStorage;
using System.Linq;
using System.Text.RegularExpressions;
@ -22,7 +24,9 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin.DataEncoders;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
@ -153,20 +157,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
var ppId = await _pullPaymentService.CreatePullPayment(new CreatePullPayment()
{
StartsAt = request.StartsAt,
ExpiresAt = request.ExpiresAt,
Period = request.Period,
BOLT11Expiration = request.BOLT11Expiration,
Name = request.Name,
Description = request.Description,
Amount = request.Amount,
Currency = request.Currency,
StoreId = storeId,
PaymentMethodIds = paymentMethods,
AutoApproveClaims = request.AutoApproveClaims
});
var ppId = await _pullPaymentService.CreatePullPayment(storeId, request);
var pp = await _pullPaymentService.GetPullPayment(ppId, false);
return this.Ok(CreatePullPaymentData(pp));
}
@ -200,13 +191,37 @@ namespace BTCPayServer.Controllers.Greenfield
[HttpPost]
[Route("~/api/v1/pull-payments/{pullPaymentId}/boltcards")]
[AllowAnonymous]
public async Task<IActionResult> RegisterBoltcard(string pullPaymentId, RegisterBoltcardRequest request)
public async Task<IActionResult> RegisterBoltcard(string pullPaymentId, RegisterBoltcardRequest request, string? onExisting = null)
{
if (pullPaymentId is null)
return PullPaymentNotFound();
var pp = await _pullPaymentService.GetPullPayment(pullPaymentId, false);
if (pp is null)
return PullPaymentNotFound();
var issuerKey = await _settingsRepository.GetIssuerKey(_env);
// LNURLW is used by deeplinks
if (request?.LNURLW is not null)
{
if (request.UID is not null)
{
ModelState.AddModelError(nameof(request.LNURLW), "You should pass either LNURLW or UID but not both");
return this.CreateValidationError(ModelState);
}
var p = ExtractP(request.LNURLW);
if (p is null)
{
ModelState.AddModelError(nameof(request.LNURLW), "The LNURLW should contains a 'p=' parameter");
return this.CreateValidationError(ModelState);
}
if (issuerKey.TryDecrypt(p) is not BoltcardPICCData picc)
{
ModelState.AddModelError(nameof(request.LNURLW), "The LNURLW 'p=' parameter cannot be decrypted");
return this.CreateValidationError(ModelState);
}
request.UID = picc.Uid;
}
if (request?.UID is null || request.UID.Length != 7)
{
ModelState.AddModelError(nameof(request.UID), "The UID is required and should be 7 bytes");
@ -217,7 +232,14 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateAPIError(400, "lnurl-not-supported", "This pull payment currency should be BTC or SATS and accept lightning");
}
var issuerKey = await _settingsRepository.GetIssuerKey(_env);
// Passing onExisting as a query parameter is used by deeplink
request.OnExisting = onExisting switch
{
nameof(OnExistingBehavior.UpdateVersion) => OnExistingBehavior.UpdateVersion,
nameof(OnExistingBehavior.KeepVersion) => OnExistingBehavior.KeepVersion,
_ => request.OnExisting
};
var version = await _dbContextFactory.LinkBoltcardToPullPayment(pullPaymentId, issuerKey, request.UID, request.OnExisting);
var keys = issuerKey.CreatePullPaymentCardKey(request.UID, version, pullPaymentId).DeriveBoltcardKeys(issuerKey);
@ -225,7 +247,7 @@ namespace BTCPayServer.Controllers.Greenfield
boltcardUrl = Request.GetAbsoluteUri(boltcardUrl);
boltcardUrl = Regex.Replace(boltcardUrl, "^https?://", "lnurlw://");
return Ok(new RegisterBoltcardResponse()
var resp = new RegisterBoltcardResponse()
{
LNURLW = boltcardUrl,
Version = version,
@ -234,7 +256,22 @@ namespace BTCPayServer.Controllers.Greenfield
K2 = Encoders.Hex.EncodeData(keys.AuthenticationKey.ToBytes()).ToUpperInvariant(),
K3 = Encoders.Hex.EncodeData(keys.K3.ToBytes()).ToUpperInvariant(),
K4 = Encoders.Hex.EncodeData(keys.K4.ToBytes()).ToUpperInvariant(),
});
};
return Ok(resp);
}
private string? ExtractP(string? url)
{
if (url is null || !Uri.TryCreate(url, UriKind.Absolute, out var uri))
return null;
int num = uri.AbsoluteUri.IndexOf('?');
if (num == -1)
return null;
string input = uri.AbsoluteUri.Substring(num);
Match match = Regex.Match(input, "p=([a-f0-9A-F]{32})");
if (!match.Success)
return null;
return match.Groups[1].Value;
}
[HttpGet("~/api/v1/pull-payments/{pullPaymentId}")]
@ -386,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);

View File

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

View File

@ -123,7 +123,7 @@ namespace BTCPayServer.Controllers.Greenfield
try
{
var strategy = network.GetDerivationSchemeParser().Parse(paymentMethod.DerivationScheme, false, true);
var strategy = network.GetDerivationSchemeParser().Parse(paymentMethod.DerivationScheme);
var deposit = new NBXplorer.KeyPathTemplates(null).GetKeyPathTemplate(DerivationFeature.Deposit);
var line = strategy.GetLineFor(deposit);
@ -173,7 +173,7 @@ namespace BTCPayServer.Controllers.Greenfield
DerivationStrategyBase strategy;
try
{
strategy = network.GetDerivationSchemeParser().Parse(paymentMethodData.DerivationScheme, false, true);
strategy = network.GetDerivationSchemeParser().Parse(paymentMethodData.DerivationScheme);
}
catch
{
@ -246,7 +246,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
var store = Store;
var storeBlob = store.GetStoreBlob();
var strategy = network.GetDerivationSchemeParser().Parse(request.DerivationScheme, false, true);
var strategy = network.GetDerivationSchemeParser().Parse(request.DerivationScheme);
if (strategy != null)
await wallet.TrackAsync(strategy);

View File

@ -518,10 +518,6 @@ namespace BTCPayServer.Controllers.Greenfield
Outputs = outputs,
AlwaysIncludeNonWitnessUTXO = true,
InputSelection = request.SelectedInputs?.Any() is true,
AllowFeeBump =
!request.RBF.HasValue ? WalletSendModel.ThreeStateBool.Maybe :
request.RBF.Value ? WalletSendModel.ThreeStateBool.Yes :
WalletSendModel.ThreeStateBool.No,
FeeSatoshiPerByte = request.FeeRate?.SatoshiPerByte,
NoChange = request.NoChange
},

View File

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

View File

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

View File

@ -92,6 +92,26 @@ namespace BTCPayServer.Controllers.Greenfield
$"{(request.Locked ? "Locking" : "Unlocking")} user failed");
}
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/users/{idOrEmail}/approve")]
public async Task<IActionResult> ApproveUser(string idOrEmail, ApproveUserRequest request)
{
var user = await _userManager.FindByIdOrEmail(idOrEmail);
if (user is null)
{
return this.UserNotFound();
}
var success = false;
if (user.RequiresApproval)
{
success = await _userService.SetUserApproval(user.Id, request.Approved, Request.GetAbsoluteRootUri());
}
return success ? Ok() : this.CreateAPIError("invalid-state",
$"{(request.Approved ? "Approving" : "Unapproving")} user failed");
}
[Authorize(Policy = Policies.CanViewUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/users/")]
public async Task<ActionResult<ApplicationUserData[]>> GetUsers()
@ -163,7 +183,9 @@ namespace BTCPayServer.Controllers.Greenfield
UserName = request.Email,
Email = request.Email,
RequiresEmailConfirmation = policies.RequiresConfirmedEmail,
RequiresApproval = policies.RequiresUserApproval,
Created = DateTimeOffset.UtcNow,
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)
@ -192,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)
{
@ -211,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);
}

View File

@ -1084,6 +1084,13 @@ namespace BTCPayServer.Controllers.Greenfield
new LockUserRequest { Locked = disabled }));
}
public override async Task<bool> ApproveUser(string idOrEmail, bool approved, CancellationToken token = default)
{
return GetFromActionResult<bool>(
await GetController<GreenfieldUsersController>().ApproveUser(idOrEmail,
new ApproveUserRequest { Approved = approved }));
}
public override async Task<OnChainWalletTransactionData> PatchOnChainWalletTransaction(string storeId,
string cryptoCode, string transactionId,
PatchOnChainTransactionRequest request, bool force = false, CancellationToken token = default)

View File

@ -2,7 +2,6 @@ using System;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
@ -109,6 +108,7 @@ namespace BTCPayServer.Controllers
{
if (User.Identity.IsAuthenticated && string.IsNullOrEmpty(returnUrl))
return RedirectToLocal();
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
@ -118,15 +118,13 @@ namespace BTCPayServer.Controllers
}
ViewData["ReturnUrl"] = returnUrl;
return View(nameof(Login), new LoginViewModel() { Email = email });
return View(nameof(Login), new LoginViewModel { Email = email });
}
[HttpPost("/login/code")]
[AllowAnonymous]
[ValidateAntiForgeryToken]
[RateLimitsFilter(ZoneLimits.Login, Scope = RateLimitsScope.RemoteAddress)]
public async Task<IActionResult> LoginWithCode(string loginCode, string returnUrl = null)
{
if (!string.IsNullOrEmpty(loginCode))
@ -134,17 +132,26 @@ namespace BTCPayServer.Controllers
var userId = _userLoginCodeService.Verify(loginCode);
if (userId is null)
{
ModelState.AddModelError(string.Empty,
"Login code was invalid");
return await Login(returnUrl, null);
TempData[WellKnownTempData.ErrorMessage] = "Login code was invalid";
return await Login(returnUrl);
}
var user = await _userManager.FindByIdAsync(userId);
_logger.LogInformation("User with ID {UserId} logged in with a login code.", user.Id);
var user = await _userManager.FindByIdAsync(userId);
if (!UserService.TryCanLogin(user, out var message))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return await Login(returnUrl);
}
_logger.LogInformation("User {Email} logged in with a login code", user!.Email);
await _signInManager.SignInAsync(user, false, "LoginCode");
return RedirectToLocal(returnUrl);
}
return await Login(returnUrl, null);
return await Login(returnUrl);
}
[HttpPost("/login")]
@ -161,24 +168,20 @@ namespace BTCPayServer.Controllers
ViewData["ReturnUrl"] = returnUrl;
if (ModelState.IsValid)
{
// Require the user to have a confirmed email before they can log on.
// Require the user to pass basic checks (approval, confirmed email, not disabled) before they can log on
var user = await _userManager.FindByEmailAsync(model.Email);
if (user != null)
const string errorMessage = "Invalid login attempt.";
if (!UserService.TryCanLogin(user, out var message))
{
if (user.RequiresEmailConfirmation && !await _userManager.IsEmailConfirmedAsync(user))
TempData.SetStatusMessageModel(new StatusMessageModel
{
ModelState.AddModelError(string.Empty,
"You must have a confirmed email to log in.");
return View(model);
}
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return View(model);
}
var fido2Devices = await _fido2Service.HasCredentials(user.Id);
var fido2Devices = await _fido2Service.HasCredentials(user!.Id);
var lnurlAuthCredentials = await _lnurlAuthService.HasCredentials(user.Id);
if (!await _userManager.IsLockedOutAsync(user) && (fido2Devices || lnurlAuthCredentials))
{
@ -196,33 +199,30 @@ namespace BTCPayServer.Controllers
};
}
return View("SecondaryLogin", new SecondaryLoginViewModel()
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWith2FaViewModel = twoFModel,
LoginWithFido2ViewModel = fido2Devices ? await BuildFido2ViewModel(model.RememberMe, user) : null,
LoginWithLNURLAuthViewModel = lnurlAuthCredentials ? await BuildLNURLAuthViewModel(model.RememberMe, user) : null,
});
}
else
{
await _userManager.AccessFailedAsync(user);
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return View(model);
}
await _userManager.AccessFailedAsync(user);
ModelState.AddModelError(string.Empty, errorMessage!);
return View(model);
}
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
if (result.Succeeded)
{
_logger.LogInformation($"User '{user.Id}' logged in.");
_logger.LogInformation("User {Email} logged in", user.Email);
return RedirectToLocal(returnUrl);
}
if (result.RequiresTwoFactor)
{
return View("SecondaryLogin", new SecondaryLoginViewModel()
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWith2FaViewModel = new LoginWith2faViewModel()
LoginWith2FaViewModel = new LoginWith2faViewModel
{
RememberMe = model.RememberMe
}
@ -230,14 +230,12 @@ namespace BTCPayServer.Controllers
}
if (result.IsLockedOut)
{
_logger.LogWarning($"User '{user.Id}' account locked out.");
_logger.LogWarning("User {Email} tried to log in, but is locked out", user.Email);
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return View(model);
}
ModelState.AddModelError(string.Empty, errorMessage);
return View(model);
}
// If we got this far, something failed, redisplay form
@ -253,7 +251,7 @@ namespace BTCPayServer.Controllers
{
return null;
}
return new LoginWithFido2ViewModel()
return new LoginWithFido2ViewModel
{
Data = r,
UserId = user.Id,
@ -263,7 +261,6 @@ namespace BTCPayServer.Controllers
return null;
}
private async Task<LoginWithLNURLAuthViewModel> BuildLNURLAuthViewModel(bool rememberMe, ApplicationUser user)
{
if (_btcPayServerEnvironment.IsSecure(HttpContext))
@ -273,15 +270,14 @@ namespace BTCPayServer.Controllers
{
return null;
}
return new LoginWithLNURLAuthViewModel()
return new LoginWithLNURLAuthViewModel
{
RememberMe = rememberMe,
UserId = user.Id,
LNURLEndpoint = new Uri(_linkGenerator.GetUriByAction(
action: nameof(UILNURLAuthController.LoginResponse),
controller: "UILNURLAuth",
values: new { userId = user.Id, action = "login", tag = "login", k1 = Encoders.Hex.EncodeData(r) }, Request.Scheme, Request.Host, Request.PathBase))
action: nameof(UILNURLAuthController.LoginResponse),
controller: "UILNURLAuth",
values: new { userId = user.Id, action = "login", tag = "login", k1 = Encoders.Hex.EncodeData(r) }, Request.Scheme, Request.Host, Request.PathBase) ?? string.Empty)
};
}
return null;
@ -298,14 +294,18 @@ namespace BTCPayServer.Controllers
}
ViewData["ReturnUrl"] = returnUrl;
var errorMessage = "Invalid login attempt.";
var user = await _userManager.FindByIdAsync(viewModel.UserId);
if (user == null)
if (!UserService.TryCanLogin(user, out var message))
{
return NotFound();
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return RedirectToAction("Login");
}
var errorMessage = string.Empty;
try
{
var k1 = Encoders.Hex.DecodeData(viewModel.LNURLEndpoint.ParseQueryString().Get("k1"));
@ -313,34 +313,33 @@ namespace BTCPayServer.Controllers
storedk1.SequenceEqual(k1))
{
_lnurlAuthService.FinalLoginStore.TryRemove(viewModel.UserId, out _);
await _signInManager.SignInAsync(user, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User logged in.");
await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User logged in");
return RedirectToLocal(returnUrl);
}
errorMessage = "Invalid login attempt.";
}
catch (Exception e)
{
errorMessage = e.Message;
}
ModelState.AddModelError(string.Empty, errorMessage);
return View("SecondaryLogin", new SecondaryLoginViewModel()
if (!string.IsNullOrEmpty(errorMessage))
{
LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(viewModel.RememberMe, user) : null,
ModelState.AddModelError(string.Empty, errorMessage);
}
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user!.Id) ? await BuildFido2ViewModel(viewModel.RememberMe, user) : null,
LoginWithLNURLAuthViewModel = viewModel,
LoginWith2FaViewModel = !user.TwoFactorEnabled
? null
: new LoginWith2faViewModel()
: new LoginWith2faViewModel
{
RememberMe = viewModel.RememberMe
}
});
}
[HttpPost("/login/fido2")]
[AllowAnonymous]
[ValidateAntiForgeryToken]
@ -352,44 +351,50 @@ namespace BTCPayServer.Controllers
}
ViewData["ReturnUrl"] = returnUrl;
var errorMessage = "Invalid login attempt.";
var user = await _userManager.FindByIdAsync(viewModel.UserId);
if (user == null)
if (!UserService.TryCanLogin(user, out var message))
{
return NotFound();
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return RedirectToAction("Login");
}
var errorMessage = string.Empty;
try
{
if (await _fido2Service.CompleteLogin(viewModel.UserId, JObject.Parse(viewModel.Response).ToObject<AuthenticatorAssertionRawResponse>()))
{
await _signInManager.SignInAsync(user, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User logged in.");
await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User {Email} logged in with FIDO2", user.Email);
return RedirectToLocal(returnUrl);
}
errorMessage = "Invalid login attempt.";
}
catch (Fido2VerificationException e)
{
errorMessage = e.Message;
}
ModelState.AddModelError(string.Empty, errorMessage);
if (!string.IsNullOrEmpty(errorMessage))
{
ModelState.AddModelError(string.Empty, errorMessage);
}
viewModel.Response = null;
return View("SecondaryLogin", new SecondaryLoginViewModel()
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWithFido2ViewModel = viewModel,
LoginWithLNURLAuthViewModel = (await _lnurlAuthService.HasCredentials(user.Id)) ? await BuildLNURLAuthViewModel(viewModel.RememberMe, user) : null,
LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user!.Id) ? await BuildLNURLAuthViewModel(viewModel.RememberMe, user) : null,
LoginWith2FaViewModel = !user.TwoFactorEnabled
? null
: new LoginWith2faViewModel()
: new LoginWith2faViewModel
{
RememberMe = viewModel.RememberMe
}
});
}
[HttpGet("/login/2fa")]
[AllowAnonymous]
public async Task<IActionResult> LoginWith2fa(bool rememberMe, string returnUrl = null)
@ -401,7 +406,6 @@ namespace BTCPayServer.Controllers
// Ensure the user has gone through the username & password screen first
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new ApplicationException($"Unable to load two-factor authentication user.");
@ -409,11 +413,11 @@ namespace BTCPayServer.Controllers
ViewData["ReturnUrl"] = returnUrl;
return View("SecondaryLogin", new SecondaryLoginViewModel()
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWith2FaViewModel = new LoginWith2faViewModel { RememberMe = rememberMe },
LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(rememberMe, user) : null,
LoginWithLNURLAuthViewModel = (await _lnurlAuthService.HasCredentials(user.Id)) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user.Id) ? await BuildFido2ViewModel(rememberMe, user) : null,
LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user.Id) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
});
}
@ -437,32 +441,32 @@ namespace BTCPayServer.Controllers
{
throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
if (!UserService.TryCanLogin(user, out var message))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return View(model);
}
var authenticatorCode = model.TwoFactorCode.Replace(" ", string.Empty, StringComparison.InvariantCulture).Replace("-", string.Empty, StringComparison.InvariantCulture);
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);
}
else if (result.IsLockedOut)
_logger.LogWarning("User {Email} entered invalid authenticator code", user.Email);
ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
return View("SecondaryLogin", new SecondaryLoginViewModel
{
_logger.LogWarning("User with ID {UserId} account locked out.", user.Id);
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
}
else
{
_logger.LogWarning("Invalid authenticator code entered for user with ID {UserId}.", user.Id);
ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
return View("SecondaryLogin", new SecondaryLoginViewModel()
{
LoginWith2FaViewModel = model,
LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(rememberMe, user) : null,
LoginWithLNURLAuthViewModel = (await _lnurlAuthService.HasCredentials(user.Id)) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
});
}
LoginWith2FaViewModel = model,
LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user.Id) ? await BuildFido2ViewModel(rememberMe, user) : null,
LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user.Id) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
});
}
[HttpGet("/login/recovery-code")]
@ -504,30 +508,35 @@ namespace BTCPayServer.Controllers
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new ApplicationException($"Unable to load two-factor authentication user.");
throw new ApplicationException("Unable to load two-factor authentication user.");
}
if (!UserService.TryCanLogin(user, out var message))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return View(model);
}
var recoveryCode = model.RecoveryCode.Replace(" ", string.Empty, StringComparison.InvariantCulture);
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 });
}
else
{
_logger.LogWarning("Invalid recovery code entered for user with ID {UserId}", user.Id);
ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
return View();
}
_logger.LogWarning("User {Email} entered invalid recovery code", user.Email);
ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
return View();
}
[HttpGet("/login/lockout")]
@ -540,7 +549,7 @@ namespace BTCPayServer.Controllers
[HttpGet("/register")]
[AllowAnonymous]
[RateLimitsFilter(ZoneLimits.Register, Scope = RateLimitsScope.RemoteAddress)]
public IActionResult Register(string returnUrl = null, bool logon = true)
public IActionResult Register(string returnUrl = null)
{
if (!CanLoginOrRegister())
{
@ -567,32 +576,35 @@ namespace BTCPayServer.Controllers
var policies = await _SettingsRepository.GetSettingAsync<PoliciesSettings>() ?? new PoliciesSettings();
if (policies.LockSubscription && !User.IsInRole(Roles.ServerAdmin))
return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
if (ModelState.IsValid)
{
var anyAdmin = (await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin)).Any();
var isFirstAdmin = !anyAdmin || (model.IsAdmin && _Options.CheatMode);
var user = new ApplicationUser
{
UserName = model.Email,
Email = model.Email,
RequiresEmailConfirmation = policies.RequiresConfirmedEmail,
Created = DateTimeOffset.UtcNow
RequiresApproval = policies.RequiresUserApproval,
Created = DateTimeOffset.UtcNow,
Approved = isFirstAdmin // auto-approve first admin
};
var result = await _userManager.CreateAsync(user, model.Password);
if (result.Succeeded)
{
var admin = await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
if (admin.Count == 0 || (model.IsAdmin && _Options.CheatMode))
if (isFirstAdmin)
{
await _RoleManager.CreateAsync(new IdentityRole(Roles.ServerAdmin));
await _userManager.AddToRoleAsync(user, Roles.ServerAdmin);
var settings = await _SettingsRepository.GetSettingAsync<ThemeSettings>();
var settings = await _SettingsRepository.GetSettingAsync<ThemeSettings>() ?? new ThemeSettings();
settings.FirstRun = false;
await _SettingsRepository.UpdateSetting<ThemeSettings>(settings);
await _SettingsRepository.UpdateSetting(settings);
await _SettingsRepository.FirstAdminRegistered(policies, _Options.UpdateUrl != null, _Options.DisableRegistration, Logs);
RegisteredAdmin = true;
}
_eventAggregator.Publish(new UserRegisteredEvent()
_eventAggregator.Publish(new UserRegisteredEvent
{
RequestUri = Request.GetAbsoluteRootUri(),
User = user,
@ -600,19 +612,31 @@ namespace BTCPayServer.Controllers
});
RegisteredUserId = user.Id;
if (!policies.RequiresConfirmedEmail)
TempData[WellKnownTempData.SuccessMessage] = "Account created.";
var requiresConfirmedEmail = policies.RequiresConfirmedEmail && !user.EmailConfirmed;
var requiresUserApproval = policies.RequiresUserApproval && !user.Approved;
if (requiresConfirmedEmail)
{
if (logon)
await _signInManager.SignInAsync(user, isPersistent: false);
TempData[WellKnownTempData.SuccessMessage] += " Please confirm your email.";
}
if (requiresUserApproval)
{
TempData[WellKnownTempData.SuccessMessage] += " The new account requires approval by an admin before you can log in.";
}
if (requiresConfirmedEmail || requiresUserApproval)
{
return RedirectToAction(nameof(Login));
}
if (logon)
{
await _signInManager.SignInAsync(user, isPersistent: false);
return RedirectToLocal(returnUrl);
}
else
{
TempData[WellKnownTempData.SuccessMessage] = "Account created, please confirm your email";
return View();
}
}
AddErrors(result);
else
{
AddErrors(result);
}
}
// If we got this far, something failed, redisplay form
@ -626,10 +650,12 @@ 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.");
return RedirectToAction(nameof(UIAccountController.Login));
_logger.LogInformation("User {Email} logged out", user!.Email);
return RedirectToAction(nameof(Login));
}
[HttpGet("/register/confirm-email")]
@ -647,25 +673,31 @@ namespace BTCPayServer.Controllers
}
var result = await _userManager.ConfirmEmailAsync(user, code);
if (!await _userManager.HasPasswordAsync(user))
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Info,
Message = "Your email has been confirmed but you still need to set your password."
});
return RedirectToAction("SetPassword", new { email = user.Email, code = await _userManager.GeneratePasswordResetTokenAsync(user) });
}
if (result.Succeeded)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
_eventAggregator.Publish(new UserConfirmedEmailEvent
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Your email has been confirmed."
User = user,
RequestUri = Request.GetAbsoluteRootUri()
});
return RedirectToAction("Login", new { email = user.Email });
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. Please set your password."
});
return await RedirectToSetPassword(user);
}
return View("Error");
@ -687,12 +719,12 @@ namespace BTCPayServer.Controllers
if (ModelState.IsValid)
{
var user = await _userManager.FindByEmailAsync(model.Email);
if (user == null || (user.RequiresEmailConfirmation && !(await _userManager.IsEmailConfirmedAsync(user))))
if (!UserService.TryCanLogin(user, out _))
{
// Don't reveal that the user does not exist or is not confirmed
return RedirectToAction(nameof(ForgotPasswordConfirmation));
}
_eventAggregator.Publish(new UserPasswordResetRequestedEvent()
_eventAggregator.Publish(new UserPasswordResetRequestedEvent
{
User = user,
RequestUri = Request.GetAbsoluteRootUri()
@ -717,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")]
@ -739,28 +777,91 @@ namespace BTCPayServer.Controllers
{
return View(model);
}
var user = await _userManager.FindByEmailAsync(model.Email);
if (user == null)
var hasPassword = user != null && await _userManager.HasPasswordAsync(user);
if (!UserService.TryCanLogin(user, out _))
{
// Don't reveal that the user does not exist
return RedirectToAction(nameof(Login));
}
var result = await _userManager.ResetPasswordAsync(user, model.Code, model.Password);
var result = await _userManager.ResetPasswordAsync(user!, model.Code, model.Password);
if (result.Succeeded)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
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)
@ -800,7 +901,7 @@ namespace BTCPayServer.Controllers
private void SetInsecureFlags()
{
TempData.SetStatusMessageModel(new StatusMessageModel()
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "You cannot login over an insecure connection. Please use HTTPS or Tor."

View File

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

View File

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

View File

@ -90,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)
{
@ -296,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
{
@ -312,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)
{
@ -326,7 +330,7 @@ namespace BTCPayServer
store.GetStoreBlob(),
createInvoice,
additionalTags: new List<string> { AppService.GetAppInternalTag(appId) },
allowOverpay: false);
allowOverpay: allowOverpay);
}
public class EditLightningAddressVM
@ -495,7 +499,7 @@ namespace BTCPayServer
});
}
private async Task<IActionResult> GetLNURLRequest(
public async Task<IActionResult> GetLNURLRequest(
string cryptoCode,
Data.StoreData store,
Data.StoreBlob blob,
@ -522,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(

View File

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

View File

@ -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) + ";"

View File

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

View File

@ -11,6 +11,7 @@ using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.Greenfield;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
@ -127,13 +128,27 @@ namespace BTCPayServer.Controllers
if (_pullPaymentHostedService.SupportsLNURL(blob))
{
var url = Url.Action("GetLNURLForPullPayment", "UILNURL", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, pullPaymentId = vm.Id }, Request.Scheme, Request.Host.ToString());
var url = Url.Action(nameof(UILNURLController.GetLNURLForPullPayment), "UILNURL", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, pullPaymentId = vm.Id }, Request.Scheme, Request.Host.ToString());
vm.LnurlEndpoint = url != null ? new Uri(url) : null;
vm.SetupDeepLink = $"boltcard://program?url={GetBoltcardDeeplinkUrl(vm, OnExistingBehavior.UpdateVersion)}";
vm.ResetDeepLink = $"boltcard://reset?url={GetBoltcardDeeplinkUrl(vm, OnExistingBehavior.KeepVersion)}";
}
return View(nameof(ViewPullPayment), vm);
}
private string GetBoltcardDeeplinkUrl(ViewPullPaymentModel vm, OnExistingBehavior onExisting)
{
var registerUrl = Url.Action(nameof(GreenfieldPullPaymentController.RegisterBoltcard), "GreenfieldPullPayment",
new
{
pullPaymentId = vm.Id,
onExisting = onExisting.ToString()
}, Request.Scheme, Request.Host.ToString());
registerUrl = Uri.EscapeDataString(registerUrl);
return registerUrl;
}
[HttpGet("stores/{storeId}/pull-payments/edit/{pullPaymentId}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> EditPullPayment(string storeId, string pullPaymentId)
@ -212,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)
@ -220,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;
@ -232,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);
}
@ -261,7 +281,8 @@ namespace BTCPayServer.Controllers
Destination = destination,
PullPaymentId = pullPaymentId,
Value = vm.ClaimedAmount,
PaymentMethodId = paymentMethodId
PaymentMethodId = paymentMethodId,
StoreId = pp.StoreId
});
if (result.Result != ClaimRequest.ClaimResult.Ok)

View File

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

View File

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

View File

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
@ -8,25 +7,22 @@ using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Models;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Services;
using BTCPayServer.Services.Mails;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using MimeKit;
namespace BTCPayServer.Controllers
{
public partial class UIServerController
{
[Route("server/users")]
[HttpGet("server/users")]
public async Task<IActionResult> ListUsers(
[FromServices] RoleManager<IdentityRole> roleManager,
UsersViewModel model,
string sortOrder = null
)
UsersViewModel model,
string sortOrder = null)
{
model = this.ParseListQuery(model ?? new UsersViewModel());
@ -57,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
@ -64,54 +62,79 @@ namespace BTCPayServer.Controllers
Name = u.UserName,
Email = u.Email,
Id = u.Id,
Verified = u.EmailConfirmed || !u.RequiresEmailConfirmation,
EmailConfirmed = u.RequiresEmailConfirmation ? u.EmailConfirmed : null,
Approved = u.RequiresApproval ? u.Approved : null,
Created = u.Created,
Roles = u.UserRoles.Select(role => role.RoleId),
Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime
Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime,
Stores = u.UserStores.OrderBy(s => !s.StoreData.Archived).ToList()
})
.ToListAsync();
return View(model);
}
[Route("server/users/{userId}")]
[HttpGet("server/users/{userId}")]
public new async Task<IActionResult> User(string userId)
{
var user = await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
var roles = await _UserManager.GetRolesAsync(user);
var userVM = new UsersViewModel.UserViewModel
var model = new UsersViewModel.UserViewModel
{
Id = user.Id,
Email = user.Email,
Verified = user.EmailConfirmed || !user.RequiresEmailConfirmation,
EmailConfirmed = user.RequiresEmailConfirmation ? user.EmailConfirmed : null,
Approved = user.RequiresApproval ? user.Approved : null,
IsAdmin = Roles.HasServerAdmin(roles)
};
return View(userVM);
return View(model);
}
[Route("server/users/{userId}")]
[HttpPost]
[HttpPost("server/users/{userId}")]
public new async Task<IActionResult> User(string userId, UsersViewModel.UserViewModel viewModel)
{
var user = await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
bool? propertiesChanged = null;
bool? adminStatusChanged = null;
bool? approvalStatusChanged = null;
if (user.RequiresApproval && viewModel.Approved.HasValue && user.Approved != viewModel.Approved.Value)
{
approvalStatusChanged = await _userService.SetUserApproval(user.Id, viewModel.Approved.Value, Request.GetAbsoluteRootUri());
}
if (user.RequiresEmailConfirmation && viewModel.EmailConfirmed.HasValue && user.EmailConfirmed != viewModel.EmailConfirmed)
{
user.EmailConfirmed = viewModel.EmailConfirmed.Value;
propertiesChanged = true;
}
var admins = await _UserManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var roles = await _UserManager.GetRolesAsync(user);
var wasAdmin = Roles.HasServerAdmin(roles);
if (!viewModel.IsAdmin && admins.Count == 1 && wasAdmin)
{
TempData[WellKnownTempData.ErrorMessage] = "This is the only Admin, so their role can't be removed until another Admin is added.";
return View(viewModel); // return
return View(viewModel);
}
if (viewModel.IsAdmin != wasAdmin)
{
var success = await _userService.SetAdminUser(user.Id, viewModel.IsAdmin);
if (success)
adminStatusChanged = await _userService.SetAdminUser(user.Id, viewModel.IsAdmin);
}
if (propertiesChanged is true)
{
propertiesChanged = await _UserManager.UpdateAsync(user) is { Succeeded: true };
}
if (propertiesChanged.HasValue || adminStatusChanged.HasValue || approvalStatusChanged.HasValue)
{
if (propertiesChanged is not false && adminStatusChanged is not false && approvalStatusChanged is not false)
{
TempData[WellKnownTempData.SuccessMessage] = "User successfully updated";
}
@ -121,45 +144,38 @@ namespace BTCPayServer.Controllers
}
}
return RedirectToAction(nameof(User), new { userId = userId });
return RedirectToAction(nameof(User), new { userId });
}
[Route("server/users/new")]
[HttpGet]
[HttpGet("server/users/new")]
public IActionResult CreateUser()
{
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
return View();
}
[Route("server/users/new")]
[HttpPost]
[HttpPost("server/users/new")]
public async Task<IActionResult> CreateUser(RegisterFromAdminViewModel model)
{
var requiresConfirmedEmail = _policiesSettings.RequiresConfirmedEmail;
ViewData["AllowRequestEmailConfirmation"] = requiresConfirmedEmail;
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
if (!_Options.CheatMode)
model.IsAdmin = false;
if (ModelState.IsValid)
{
IdentityResult result;
var user = new ApplicationUser
{
UserName = model.Email,
Email = model.Email,
EmailConfirmed = model.EmailConfirmed,
RequiresEmailConfirmation = requiresConfirmedEmail,
RequiresEmailConfirmation = _policiesSettings.RequiresConfirmedEmail,
RequiresApproval = _policiesSettings.RequiresUserApproval,
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)
{
@ -167,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));
}
@ -223,7 +232,6 @@ namespace BTCPayServer.Controllers
{
if (await _userService.IsUserTheOnlyOneAdmin(user))
{
// return
return View("Confirm", new ConfirmModel("Delete admin",
$"Unable to proceed: As the user <strong>{Html.Encode(user.Email)}</strong> is the last enabled admin, it cannot be removed."));
}
@ -281,6 +289,29 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(ListUsers));
}
[HttpGet("server/users/{userId}/approve")]
public async Task<IActionResult> ApproveUser(string userId, bool approved)
{
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
return View("Confirm", new ConfirmModel($"{(approved ? "Approve" : "Unapprove")} user", $"The user <strong>{Html.Encode(user.Email)}</strong> will be {(approved ? "approved" : "unapproved")}. Are you sure?", (approved ? "Approve" : "Unapprove")));
}
[HttpPost("server/users/{userId}/approve")]
public async Task<IActionResult> ApproveUserPost(string userId, bool approved)
{
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
await _userService.SetUserApproval(userId, approved, Request.GetAbsoluteRootUri());
TempData[WellKnownTempData.SuccessMessage] = $"User {(approved ? "approved" : "unapproved")}";
return RedirectToAction(nameof(ListUsers));
}
[HttpGet("server/users/{userId}/verification-email")]
public async Task<IActionResult> SendVerificationEmail(string userId)
{

View File

@ -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?}")]

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

View File

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

View File

@ -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;
@ -27,26 +27,25 @@ namespace BTCPayServer.Controllers
return NotFound();
var blob = store.GetStoreBlob();
var storeSetupComplete = blob.EmailSettings?.IsComplete() is true;
if (!storeSetupComplete && !TempData.HasStatusMessage())
if (blob.EmailSettings?.IsComplete() is not true && !TempData.HasStatusMessage())
{
var emailSender = await _emailSenderFactory.GetEmailSender(store.Id) as StoreEmailSender;
var hasServerFallback = await IsSetupComplete(emailSender?.FallbackSender);
var message = hasServerFallback
? "Emails will be sent with the email settings of the server"
: "You need to configure email settings before this feature works";
TempData.SetStatusMessageModel(new StatusMessageModel
if (!await IsSetupComplete(emailSender?.FallbackSender))
{
Severity = hasServerFallback ? StatusMessageModel.StatusSeverity.Info : StatusMessageModel.StatusSeverity.Warning,
Html = $"{message}. <a class='alert-link' href='{Url.Action("StoreEmailSettings", new { storeId })}'>Configure store email settings</a>."
});
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Warning,
Html = $"You need to configure email settings before this feature works. <a class='alert-link' href='{Url.Action("StoreEmailSettings", new { storeId })}'>Configure store email settings</a>."
});
}
}
var vm = new StoreEmailRuleViewModel { Rules = blob.EmailRules ?? new List<StoreEmailRule>() };
var vm = new StoreEmailRuleViewModel { Rules = blob.EmailRules ?? [] };
return View(vm);
}
[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>();
@ -122,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
@ -172,37 +171,58 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/email-settings")]
public IActionResult StoreEmailSettings()
public async Task<IActionResult> StoreEmailSettings(string storeId)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var data = store.GetStoreBlob().EmailSettings ?? new EmailSettings();
return View(new EmailsViewModel(data));
var blob = store.GetStoreBlob();
var data = blob.EmailSettings ?? new EmailSettings();
var fallbackSettings = await _emailSenderFactory.GetEmailSender(store.Id) is StoreEmailSender { FallbackSender: not null } storeSender
? await storeSender.FallbackSender.GetEmailSettings()
: null;
var vm = new EmailsViewModel(data, fallbackSettings);
return View(vm);
}
[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();
model.FallbackSettings = await _emailSenderFactory.GetEmailSender(store.Id) is StoreEmailSender { FallbackSender: not null } storeSender
? await storeSender.FallbackSender.GetEmailSettings()
: null;
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.";
@ -220,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 (new EmailsViewModel(storeBlob.EmailSettings).PasswordSet && storeBlob.EmailSettings != null)
if (storeBlob.EmailSettings != null && new EmailsViewModel(storeBlob.EmailSettings, model.FallbackSettings).PasswordSet)
{
model.Settings.Password = storeBlob.EmailSettings.Password;
}
@ -238,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)

View File

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

View File

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

View File

@ -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);
@ -90,9 +92,17 @@ namespace BTCPayServer.Controllers
if (vm.WalletFile != null)
{
if (!_onChainWalletParsers.TryParseWalletFile(await ReadAllText(vm.WalletFile), network, out strategy, out var error))
string fileContent = null;
try
{
ModelState.AddModelError(nameof(vm.WalletFile), $"Importing wallet failed: {error}");
fileContent = await ReadAllText(vm.WalletFile);
}
catch
{
}
if (fileContent is null || !_onChainWalletParsers.TryParseWalletFile(fileContent, network, out strategy, out _))
{
ModelState.AddModelError(nameof(vm.WalletFile), $"Import failed, make sure you import a compatible wallet format");
return View(vm.ViewName, vm);
}
}
@ -189,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);
@ -227,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);
@ -348,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);
@ -363,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);
@ -432,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 _);
@ -541,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 _);
@ -604,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);
@ -650,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);
@ -669,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 _);
@ -687,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);
@ -706,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);

View File

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

View File

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

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