Compare commits

...

76 Commits

Author SHA1 Message Date
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
110 changed files with 1775 additions and 680 deletions

View File

@ -32,9 +32,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="HtmlSanitizer" Version="8.0.838" />
<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="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

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

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

@ -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,7 +16,9 @@ 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
{

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

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

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

View File

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

View File

@ -2160,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)]
@ -3457,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));
@ -3484,6 +3496,17 @@ 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)]

View File

@ -2620,9 +2620,9 @@ namespace BTCPayServer.Tests
// Receipt
s.Driver.WaitForElement(By.Id("ReceiptLink")).Click();
var additionalData = s.Driver.FindElement(By.CssSelector("#AdditionalData table"));
var items = additionalData.FindElements(By.CssSelector("tbody tr"));
var sums = additionalData.FindElements(By.CssSelector("tfoot tr"));
var cartData = s.Driver.FindElement(By.CssSelector("#CartData table"));
var items = cartData.FindElements(By.CssSelector("tbody tr"));
var sums = cartData.FindElements(By.CssSelector("tfoot tr"));
Assert.Equal(2, items.Count);
Assert.Equal(4, sums.Count);
Assert.Contains("Manual entry 1", items[0].FindElement(By.CssSelector("th")).Text);
@ -2638,6 +2638,33 @@ namespace BTCPayServer.Tests
Assert.Contains("Total", sums[3].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 222,21 €", sums[3].FindElement(By.CssSelector("td")).Text);
// Receipt print
s.Driver.FindElement(By.Id("ReceiptLinkPrint")).Click();
windows = s.Driver.WindowHandles;
Assert.Equal(3, windows.Count);
s.Driver.SwitchTo().Window(windows[2]);
var paymentDetails = s.Driver.WaitForElement(By.CssSelector("#PaymentDetails table"));
items = paymentDetails.FindElements(By.CssSelector("tr.cart-data"));
sums = paymentDetails.FindElements(By.CssSelector("tr.sums-data"));
Assert.Equal(2, items.Count);
Assert.Equal(4, sums.Count);
Assert.Contains("Manual entry 1", items[0].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1 234,00 €", items[0].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Manual entry 2", items[1].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("0,56 €", items[1].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Subtotal", sums[0].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1 234,56 €", sums[0].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Discount", sums[1].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("10% = 123,46 €", sums[1].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Tip", sums[2].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("10% = 111,11 €", sums[2].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Total", sums[3].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1 222,21 €", sums[3].FindElement(By.CssSelector(".val")).Text);
s.Driver.Close();
s.Driver.SwitchTo().Window(windows[1]);
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
// Once more with items
s.GoToUrl(editUrl);
s.Driver.FindElement(By.Id("ShowItems")).Click();
@ -2675,22 +2702,40 @@ namespace BTCPayServer.Tests
// Receipt
s.Driver.WaitForElement(By.Id("ReceiptLink")).Click();
additionalData = s.Driver.FindElement(By.CssSelector("#AdditionalData table"));
items = additionalData.FindElements(By.CssSelector("tbody tr"));
sums = additionalData.FindElements(By.CssSelector("tfoot tr"));
cartData = s.Driver.FindElement(By.CssSelector("#CartData table"));
items = cartData.FindElements(By.CssSelector("tbody tr"));
sums = cartData.FindElements(By.CssSelector("tfoot tr"));
Assert.Equal(3, items.Count);
Assert.Equal(2, sums.Count);
Assert.Single(sums);
Assert.Contains("Black Tea", items[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 x 1,00 € = 1,00 €", items[0].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Green Tea", items[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("2 x 1,00 € = 2,00 €", items[1].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Manual entry 1", items[2].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1,23 €", items[2].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Subtotal", sums[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("Total", sums[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("4,23 €", sums[0].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Total", sums[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("4,23 €", sums[1].FindElement(By.CssSelector("td")).Text);
// Receipt print
s.Driver.FindElement(By.Id("ReceiptLinkPrint")).Click();
windows = s.Driver.WindowHandles;
Assert.Equal(2, windows.Count);
s.Driver.SwitchTo().Window(windows[1]);
paymentDetails = s.Driver.WaitForElement(By.CssSelector("#PaymentDetails table"));
items = paymentDetails.FindElements(By.CssSelector("tr.cart-data"));
sums = paymentDetails.FindElements(By.CssSelector("tr.sums-data"));
Assert.Equal(3, items.Count);
Assert.Single(sums);
Assert.Contains("Black Tea", items[0].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1 x 1,00 € = 1,00 €", items[0].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Green Tea", items[1].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("2 x 1,00 € = 2,00 €", items[1].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Manual entry 1", items[2].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1,23 €", items[2].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Total", sums[0].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("4,23 €", sums[0].FindElement(By.CssSelector(".val")).Text);
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
// Guest user can access recent transactions
s.GoToHome();
@ -2837,9 +2882,9 @@ namespace BTCPayServer.Tests
// Receipt
s.Driver.WaitForElement(By.Id("ReceiptLink")).Click();
var additionalData = s.Driver.FindElement(By.CssSelector("#AdditionalData table"));
var items = additionalData.FindElements(By.CssSelector("tbody tr"));
var sums = additionalData.FindElements(By.CssSelector("tfoot tr"));
var cartData = s.Driver.FindElement(By.CssSelector("#CartData table"));
var items = cartData.FindElements(By.CssSelector("tbody tr"));
var sums = cartData.FindElements(By.CssSelector("tfoot tr"));
Assert.Equal(7, items.Count);
Assert.Equal(4, sums.Count);
Assert.Contains("Black Tea", items[0].FindElement(By.CssSelector("th")).Text);

View File

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

View File

@ -2814,7 +2814,7 @@ namespace BTCPayServer.Tests
Password = "store@store.com",
Port = 1234,
Server = "store.com"
}), "", true));
}), ""));
Assert.Equal("store@store.com", (await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login);
}
@ -2985,7 +2985,7 @@ namespace BTCPayServer.Tests
}
[Fact]
[Trait("Selenium", "Selenium")]
[Trait("Integration", "Integration")]
public async Task CanCreateReports()
{
using var tester = CreateServerTester(newDb: true);
@ -3093,6 +3093,48 @@ namespace BTCPayServer.Tests
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.4-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.4-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.4-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.4-beta
image: btcpayserver/lnd:v0.18.1-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"

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.22" />
<PackageReference Include="BTCPayServer.NTag424" Version="1.0.23" />
<PackageReference Include="YamlDotNet" Version="8.0.0" />
<PackageReference Include="BIP78.Sender" Version="0.2.2" />
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.2" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.5.4" />
<PackageReference Include="CsvHelper" Version="15.0.5" />
<PackageReference Include="Dapper" Version="2.1.28" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.6.0" />
<PackageReference Include="CsvHelper" Version="32.0.3" />
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Fido2" Version="2.0.2" />
<PackageReference Include="Fido2.AspNet" Version="2.0.2" />
<PackageReference Include="LNURL" Version="0.0.34" />
@ -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

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

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

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

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

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

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

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

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

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

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

@ -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)
@ -287,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);
@ -370,7 +375,7 @@ namespace BTCPayServer.Controllers
break;
}
if (command == "approve-pay")
if (command == "approve-pay" && !vm.HasPayoutProcessor)
{
goto case "pay";
}
@ -486,16 +491,18 @@ namespace BTCPayServer.Controllers
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));
@ -577,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

@ -195,10 +195,11 @@ namespace BTCPayServer.Controllers
if (store == null)
return NotFound();
ViewBag.UseCustomSMTP = useCustomSMTP;
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);

View File

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

View File

@ -738,7 +738,7 @@ namespace BTCPayServer.Controllers
foreach (var transactionOutput in vm.Outputs.Where(output => output.Labels?.Any() is true))
{
var labels = transactionOutput.Labels.Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
var walletObjectAddress = new WalletObjectId(walletId, WalletObjectData.Types.Address, transactionOutput.DestinationAddress.ToLowerInvariant());
var walletObjectAddress = new WalletObjectId(walletId, WalletObjectData.Types.Address, transactionOutput.DestinationAddress);
var obj = await WalletRepository.GetWalletObject(walletObjectAddress);
if (obj is null)
{

View File

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

View File

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

View File

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

View File

@ -37,11 +37,11 @@ namespace BTCPayServer.Filters
}
// If we have an appId, we can redirect to the canonical domain
if ((string)context.RouteContext.RouteData.Values["appId"] is { } appId && !req.IsOnion())
if ((string)context.RouteContext.RouteData.Values["appId"] is { } appId)
{
var redirectDomain = mapping.FirstOrDefault(item => item.AppId == appId)?.Domain;
// App is accessed via path, redirect to canonical domain
if (!string.IsNullOrEmpty(redirectDomain) && req.Method != "POST" && !req.HasFormContentType)
if (!string.IsNullOrEmpty(redirectDomain) && req.Method != "POST" && !req.HasFormContentType && !req.IsOnion())
{
var uri = new UriBuilder(req.Scheme, redirectDomain);
if (req.Host.Port.HasValue)

View File

@ -7,11 +7,13 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Logging;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Rating;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Rates;
@ -47,7 +49,7 @@ namespace BTCPayServer.HostedServices
public class PullPaymentHostedService : BaseAsyncService
{
private readonly string[] _lnurlSupportedCurrencies = { "BTC", "SATS" };
public class CancelRequest
{
public CancelRequest(string pullPaymentId)
@ -107,7 +109,23 @@ namespace BTCPayServer.HostedServices
}
}
}
public Task<string> CreatePullPayment(string storeId, CreatePullPaymentRequest request)
{
return 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 = request.PaymentMethods.Select(p => PaymentMethodId.Parse(p)).ToArray(),
AutoApproveClaims = request.AutoApproveClaims
});
}
public async Task<string> CreatePullPayment(CreatePullPayment create)
{
ArgumentNullException.ThrowIfNull(create);
@ -263,7 +281,7 @@ namespace BTCPayServer.HostedServices
return await query.FirstOrDefaultAsync(data => data.Id == pullPaymentId);
}
record TopUpRequest(string PullPaymentId, InvoiceEntity InvoiceEntity);
class PayoutRequest
{
public PayoutRequest(TaskCompletionSource<ClaimRequest.ClaimResponse> completionSource,
@ -323,10 +341,21 @@ namespace BTCPayServer.HostedServices
{
payoutHandler.StartBackgroundCheck(Subscribe);
}
_eventAggregator.Subscribe<Events.InvoiceEvent>(TopUpInvoiceCore);
return new[] { Loop() };
}
private void TopUpInvoiceCore(InvoiceEvent evt)
{
if (evt.EventCode == InvoiceEventCode.Completed || evt.EventCode == InvoiceEventCode.MarkedCompleted)
{
foreach (var pullPaymentId in evt.Invoice.GetInternalTags("PULLPAY#"))
{
_Channel.Writer.TryWrite(new TopUpRequest(pullPaymentId, evt.Invoice));
}
}
}
private void Subscribe(params Type[] events)
{
foreach (Type @event in events)
@ -339,6 +368,11 @@ namespace BTCPayServer.HostedServices
{
await foreach (var o in _Channel.Reader.ReadAllAsync())
{
if (o is TopUpRequest topUp)
{
await HandleTopUp(topUp);
}
if (o is PayoutRequest req)
{
await HandleCreatePayout(req);
@ -373,10 +407,44 @@ namespace BTCPayServer.HostedServices
}
}
private async Task HandleTopUp(TopUpRequest topUp)
{
var pp = await this.GetPullPayment(topUp.PullPaymentId, false);
var currency = pp.GetBlob().Currency;
using var ctx = _dbContextFactory.CreateContext();
var payout = new Data.PayoutData()
{
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)),
Date = DateTimeOffset.UtcNow,
State = PayoutState.Completed,
PullPaymentDataId = pp.Id,
PaymentMethodId = new PaymentMethodId("BTC", PaymentTypes.LightningLike).ToString(),
Destination = null,
StoreDataId = pp.StoreId
};
if (topUp.InvoiceEntity.Currency != currency ||
currency is not ("SATS" or "BTC"))
return;
var paidAmount = topUp.InvoiceEntity.Price;
var cryptoAmount = paidAmount;
var payoutBlob = new PayoutBlob()
{
CryptoAmount = -cryptoAmount,
Amount = -paidAmount,
Destination = topUp.InvoiceEntity.Id,
Metadata = new JObject(),
};
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.Payouts.AddAsync(payout);
await ctx.SaveChangesAsync();
}
public bool SupportsLNURL(PullPaymentBlob blob)
{
var pms = blob.SupportedPaymentMethods.FirstOrDefault(id =>
id.PaymentType == LightningPaymentType.Instance &&
var pms = blob.SupportedPaymentMethods.FirstOrDefault(id =>
id.PaymentType == LightningPaymentType.Instance &&
_networkProvider.DefaultNetwork.CryptoCode == id.CryptoCode);
return pms is not null && _lnurlSupportedCurrencies.Contains(blob.Currency);
}
@ -633,7 +701,7 @@ namespace BTCPayServer.HostedServices
{
Amount = claimed,
Destination = req.ClaimRequest.Destination.ToString(),
Metadata = req.ClaimRequest.Metadata?? new JObject(),
Metadata = req.ClaimRequest.Metadata ?? new JObject(),
};
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.Payouts.AddAsync(payout);
@ -826,6 +894,10 @@ namespace BTCPayServer.HostedServices
return time;
}
public static string GetInternalTag(string id)
{
return $"PULLPAY#{id}";
}
class InternalPayoutPaidRequest
{
@ -880,25 +952,25 @@ namespace BTCPayServer.HostedServices
{
null when destination.Amount is null && ppCurrency is null => ("Amount is not specified in destination or payout request", null),
null when destination.Amount is null => (null, null),
null when destination.Amount != null => (null,destination.Amount),
not null when destination.Amount is null => (null,amount),
null when destination.Amount != null => (null, destination.Amount),
not null when destination.Amount is null => (null, amount),
not null when destination.Amount != null && amount != destination.Amount &&
destination.IsExplicitAmountMinimum &&
payoutCurrency == "BTC" && ppCurrency == "SATS" &&
new Money(amount.Value, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) < destination.Amount =>
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null),
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount", null),
not null when destination.Amount != null && amount != destination.Amount &&
destination.IsExplicitAmountMinimum &&
!(payoutCurrency == "BTC" && ppCurrency == "SATS") &&
amount < destination.Amount =>
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null),
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount", null),
not null when destination.Amount != null && amount != destination.Amount &&
!destination.IsExplicitAmountMinimum =>
($"Amount is implied in destination ({destination.Amount}) that does not match the payout amount provided {amount})", null),
_ => (null, amount)
};
}
public static string GetErrorMessage(ClaimResult result)
{
switch (result)

View File

@ -304,6 +304,7 @@ namespace BTCPayServer.Hosting
});
services.TryAddSingleton<BTCPayNetworkProvider>();
services.AddExceptionHandler<PluginExceptionHandler>();
services.TryAddSingleton<AppService>();
services.AddTransient<PluginService>();
services.AddSingleton<PluginHookService>();
@ -347,6 +348,8 @@ namespace BTCPayServer.Hosting
htmlSanitizer.AllowedAttributes.Add("webkitallowfullscreen");
htmlSanitizer.AllowedAttributes.Add("allowfullscreen");
htmlSanitizer.AllowedSchemes.Add("mailto");
htmlSanitizer.AllowedSchemes.Add("bitcoin");
htmlSanitizer.AllowedSchemes.Add("lightning");
return htmlSanitizer;
});
@ -385,6 +388,7 @@ namespace BTCPayServer.Hosting
services.AddReportProvider<ProductsReportProvider>();
services.AddReportProvider<PayoutsReportProvider>();
services.AddReportProvider<LegacyInvoiceExportReportProvider>();
services.AddReportProvider<RefundsReportProvider>();
services.AddWebhooks();
services.AddSingleton<BitcoinLikePayoutHandler>();
services.AddSingleton<IPayoutHandler>(provider => provider.GetRequiredService<BitcoinLikePayoutHandler>());

View File

@ -293,6 +293,7 @@ namespace BTCPayServer.Hosting
app.UseStatusCodePagesWithReExecute("/errors/{0}");
app.UseExceptionHandler("/errors/{0}");
app.UsePayServer();
app.UseRouting();
app.UseCors();

View File

@ -17,8 +17,9 @@ namespace BTCPayServer.Models.InvoicingModels
public decimal Amount { get; set; }
public DateTimeOffset Timestamp { get; set; }
public Dictionary<string, object> AdditionalData { get; set; }
public Dictionary<string, object> CartData { get; set; }
public ReceiptOptions ReceiptOptions { get; set; }
public List<ViewPaymentRequestViewModel.PaymentRequestInvoicePayment> Payments { get; set; }
public string OrderUrl { get; set; }
public string RedirectUrl { get; set; }
}
}

View File

@ -19,6 +19,7 @@ namespace BTCPayServer.Models.WalletViewModels
public IEnumerable<PaymentMethodId> PaymentMethods { get; set; }
public PayoutState PayoutState { get; set; }
public string PullPaymentName { get; set; }
public bool HasPayoutProcessor { get; set; }
public class PayoutModel
{

View File

@ -2,6 +2,7 @@ using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using NBitcoin;
namespace BTCPayServer.Models.WalletViewModels
@ -17,7 +18,7 @@ namespace BTCPayServer.Models.WalletViewModels
public string BackUrl { get; set; }
public string ReturnUrl { get; set; }
public PSBT GetSourcePSBT(Network network)
public PSBT GetSourcePSBT(Network network, ModelStateDictionary modelState)
{
if (!string.IsNullOrEmpty(OtherPSBT))
{
@ -25,12 +26,12 @@ namespace BTCPayServer.Models.WalletViewModels
{
return NBitcoin.PSBT.Parse(OtherPSBT, network);
}
catch
{ }
catch (Exception ex)
{ modelState.AddModelError(nameof(OtherPSBT), ex.Message); }
}
return null;
}
public async Task<PSBT> GetPSBT(Network network)
public async Task<PSBT> GetPSBT(Network network, ModelStateDictionary modelState)
{
if (UploadedPSBTFile != null)
{
@ -45,8 +46,9 @@ namespace BTCPayServer.Models.WalletViewModels
{
return NBitcoin.PSBT.Load(bytes, network);
}
catch
catch (FormatException ex)
{
modelState.AddModelError(nameof(UploadedPSBTFile), ex.Message);
return null;
}
}
@ -56,8 +58,10 @@ namespace BTCPayServer.Models.WalletViewModels
{
return NBitcoin.PSBT.Parse(PSBT, network);
}
catch
{ }
catch (FormatException ex)
{
modelState.AddModelError(nameof(UploadedPSBTFile), ex.Message);
}
}
return null;
}

View File

@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using NBitcoin;
namespace BTCPayServer.Models.WalletViewModels
@ -35,9 +36,9 @@ namespace BTCPayServer.Models.WalletViewModels
public IFormFile UploadedPSBTFile { get; set; }
public async Task<PSBT> GetPSBT(Network network)
public async Task<PSBT> GetPSBT(Network network, ModelStateDictionary modelState)
{
var psbt = await GetPSBTCore(network);
var psbt = await GetPSBTCore(network, modelState);
if (psbt != null)
{
Decoded = psbt.ToString();
@ -52,7 +53,7 @@ namespace BTCPayServer.Models.WalletViewModels
}
public bool InvalidPSBT { get; set; }
async Task<PSBT> GetPSBTCore(Network network)
async Task<PSBT> GetPSBTCore(Network network, ModelStateDictionary modelState)
{
if (UploadedPSBTFile != null)
{
@ -68,16 +69,20 @@ namespace BTCPayServer.Models.WalletViewModels
}
return NBitcoin.PSBT.Load(bytes, network);
}
catch (Exception)
catch (Exception ex)
{
using var stream = new StreamReader(UploadedPSBTFile.OpenReadStream());
PSBT = await stream.ReadToEndAsync();
modelState.Remove(nameof(PSBT));
modelState.AddModelError(nameof(PSBT), ex.Message);
InvalidPSBT = true;
}
}
if (SigningContext != null && !string.IsNullOrEmpty(SigningContext.PSBT))
{
PSBT = SigningContext.PSBT;
modelState.Remove(nameof(PSBT));
InvalidPSBT = false;
}
if (!string.IsNullOrEmpty(PSBT))
{
@ -86,8 +91,11 @@ namespace BTCPayServer.Models.WalletViewModels
InvalidPSBT = false;
return NBitcoin.PSBT.Parse(PSBT, network);
}
catch
{ InvalidPSBT = true; }
catch (Exception ex) when (!InvalidPSBT)
{
modelState.AddModelError(nameof(PSBT), ex.Message);
InvalidPSBT = true;
}
}
return null;
}

View File

@ -3,10 +3,10 @@ using Newtonsoft.Json;
namespace BTCPayServer.Services.Altcoins.Monero.RPC.Models
{
public partial class SyncInfoResponse
public partial class GetInfoResponse
{
[JsonProperty("height")] public long Height { get; set; }
[JsonProperty("peers")] public List<Peer> Peers { get; set; }
[JsonProperty("busy_syncing")] public bool BusySyncing { get; set; }
[JsonProperty("status")] public string Status { get; set; }
[JsonProperty("target_height")] public long? TargetHeight { get; set; }
}

View File

@ -0,0 +1,10 @@
using Newtonsoft.Json;
namespace BTCPayServer.Services.Altcoins.Monero.RPC.Models
{
public partial class OpenWalletErrorResponse
{
[JsonProperty("code")] public int Code { get; set; }
[JsonProperty("message")] public string Message { get; set; }
}
}

View File

@ -0,0 +1,10 @@
using Newtonsoft.Json;
namespace BTCPayServer.Services.Altcoins.Monero.RPC.Models
{
public partial class OpenWalletRequest
{
[JsonProperty("filename")] public string Filename { get; set; }
[JsonProperty("password")] public string Password { get; set; }
}
}

View File

@ -0,0 +1,12 @@
using Newtonsoft.Json;
namespace BTCPayServer.Services.Altcoins.Monero.RPC.Models
{
public partial class OpenWalletResponse
{
[JsonProperty("id")] public string Id { get; set; }
[JsonProperty("jsonrpc")] public string Jsonrpc { get; set; }
[JsonProperty("result")] public object Result { get; set; }
[JsonProperty("error")] public OpenWalletErrorResponse Error { get; set; }
}
}

View File

@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Configuration;
using BTCPayServer.Logging;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Hosting.Internal;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace BTCPayServer.Plugins
{
public class PluginExceptionHandler : IExceptionHandler
{
readonly string _pluginDir;
readonly IHostApplicationLifetime _applicationLifetime;
private readonly Logs _logs;
public PluginExceptionHandler(IOptions<DataDirectories> options, IHostApplicationLifetime applicationLifetime, Logs logs)
{
_applicationLifetime = applicationLifetime;
_logs = logs;
_pluginDir = options.Value.PluginDir;
}
public ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
if (!GetDisablePluginIfCrash(httpContext) ||
!PluginManager.IsExceptionByPlugin(exception, out var pluginName))
return ValueTask.FromResult(false);
_logs.Configuration.LogError(exception, $"Unhandled exception caused by plugin '{pluginName}', disabling it and restarting...");
_ = Task.Delay(3000).ContinueWith((t) => _applicationLifetime.StopApplication());
// Returning true here means we will see Error 500 error message.
// Returning false means that the user will see a stacktrace.
return ValueTask.FromResult(false);
}
internal static bool GetDisablePluginIfCrash(HttpContext httpContext)
{
return httpContext.Items.TryGetValue("DisablePluginIfCrash", out object renderingDashboard) ||
renderingDashboard is not true;
}
internal static void SetDisablePluginIfCrash(HttpContext httpContext)
{
httpContext.Items.TryAdd("DisablePluginIfCrash", true);
}
}
}

View File

@ -258,12 +258,27 @@ namespace BTCPayServer.Plugins
private static IEnumerable<IBTCPayServerPlugin> GetPluginInstancesFromAssembly(Assembly assembly)
{
return assembly.GetTypes().Where(type =>
return GetTypesNotCrash(assembly).Where(type =>
typeof(IBTCPayServerPlugin).IsAssignableFrom(type) && type != typeof(PluginService.AvailablePlugin) &&
!type.IsAbstract).
Select(type => (IBTCPayServerPlugin)Activator.CreateInstance(type, Array.Empty<object>()));
}
private static IEnumerable<Type> GetTypesNotCrash(Assembly assembly)
{
try
{
// Strange crash with selenium
if (assembly.FullName.Contains("Selenium", StringComparison.OrdinalIgnoreCase))
return Array.Empty<Type>();
return assembly.GetTypes();
}
catch(ReflectionTypeLoadException ex)
{
return ex.Types.Where(t => t is not null).ToArray();
}
}
private static IBTCPayServerPlugin GetPluginInstanceFromAssembly(string pluginIdentifier, Assembly assembly)
{
return GetPluginInstancesFromAssembly(assembly).FirstOrDefault(plugin => plugin.Identifier == pluginIdentifier);

View File

@ -356,11 +356,9 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
var receiptData = new JObject();
if (choice is not null)
{
receiptData = JObject.FromObject(new Dictionary<string, string>
{
{"Title", choice.Title},
{"Description", choice.Description},
});
var dict = new Dictionary<string, string> { { "Title", choice.Title } };
if (!string.IsNullOrEmpty(choice.Description)) dict["Description"] = choice.Description;
receiptData = JObject.FromObject(dict);
}
else if (jposData is not null)
{

View File

@ -1,87 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Plugins.Shopify.ApiModels;
namespace BTCPayServer.Plugins.Shopify
{
public class OrderTransactionRegisterLogic
{
private readonly ShopifyApiClient _client;
public OrderTransactionRegisterLogic(ShopifyApiClient client)
{
_client = client;
}
private static string[] _keywords = new[] { "bitcoin", "btc", "btcpayserver", "btcpay server" };
public async Task<TransactionsCreateResp> Process(string orderId, string invoiceId, string currency, string amountCaptured, bool success)
{
currency = currency.ToUpperInvariant().Trim();
var existingShopifyOrderTransactions = (await _client.TransactionsList(orderId)).transactions;
//if there isn't a record for btcpay payment gateway, abort
var baseParentTransaction = existingShopifyOrderTransactions.FirstOrDefault(holder => _keywords.Any(a => holder.gateway.Contains(a, StringComparison.InvariantCultureIgnoreCase)));
if (baseParentTransaction is null)
{
return null;
}
//technically, this exploit should not be possible as we use internal invoice tags to verify that the invoice was created by our controlled, dedicated endpoint.
if (currency.ToUpperInvariant().Trim() != baseParentTransaction.currency.ToUpperInvariant().Trim())
{
// because of parent_id present, currency will always be the one from parent transaction
// malicious attacker could potentially exploit this by creating invoice
// in different currency and paying that one, registering order on Shopify as paid
// so if currency is supplied and is different from parent transaction currency we just won't register
return null;
}
var kind = "capture";
var parentId = baseParentTransaction.id;
var status = success ? "success" : "failure";
//find all existing transactions recorded around this invoice id
var existingShopifyOrderTransactionsOnSameInvoice =
existingShopifyOrderTransactions.Where(holder => holder.authorization == invoiceId);
//filter out the successful ones
var successfulActions =
existingShopifyOrderTransactionsOnSameInvoice.Where(holder => holder.status == "success").ToArray();
//of the successful ones, get the ones we registered as a valid payment
var successfulCaptures = successfulActions.Where(holder => holder.kind == "capture").ToArray();
//of the successful ones, get the ones we registered as a voiding of a previous successful payment
var refunds = successfulActions.Where(holder => holder.kind == "refund").ToArray();
//if we are working with a non-success registration, but see that we have previously registered this invoice as a success, we switch to creating a "void" transaction, which in shopify terms is a refund.
if (!success && successfulCaptures.Length > 0 && (successfulCaptures.Length - refunds.Length) > 0)
{
kind = "void";
parentId = successfulCaptures.Last().id;
status = "success";
}
//if we are working with a success registration, but can see that we have already had a successful transaction saved, get outta here
else if (success && successfulCaptures.Length > 0 && (successfulCaptures.Length - refunds.Length) > 0)
{
return null;
}
var createTransaction = new TransactionsCreateReq
{
transaction = new TransactionsCreateReq.DataHolder
{
parent_id = parentId,
currency = currency,
amount = amountCaptured,
kind = kind,
gateway = "BTCPayServer",
source = "external",
authorization = invoiceId,
status = status
}
};
var createResp = await _client.TransactionCreate(orderId, createTransaction);
return createResp;
}
}
}

View File

@ -35,10 +35,10 @@ namespace BTCPayServer.Plugins.Shopify
}
private HttpRequestMessage CreateRequest(string shopName, HttpMethod method, string action,
string relativeUrl = null)
string relativeUrl = null, string apiVersion = "2020-07")
{
var url =
$"https://{(shopName.Contains('.', StringComparison.InvariantCulture) ? shopName : $"{shopName}.myshopify.com")}/{relativeUrl ?? ("admin/api/2020-07/" + action)}";
$"https://{(shopName.Contains('.', StringComparison.InvariantCulture) ? shopName : $"{shopName}.myshopify.com")}/{relativeUrl ?? ($"admin/api/{apiVersion}/" + action)}";
var req = new HttpRequestMessage(method, url);
return req;
}
@ -115,6 +115,15 @@ namespace BTCPayServer.Plugins.Shopify
return JObject.Parse(strResp)["order"].ToObject<ShopifyOrder>();
}
public async Task<ShopifyOrder> CancelOrder(string orderId)
{
var req = CreateRequest(_credentials.ShopName, HttpMethod.Post,
$"orders/{orderId}/cancel.json?restock=true", null, "2024-04");
var strResp = await SendRequest(req);
return JObject.Parse(strResp)["order"].ToObject<ShopifyOrder>();
}
public async Task<long> OrdersCount()
{

View File

@ -1,116 +0,0 @@
using System;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Plugins.Shopify.Models;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.Plugins.Shopify
{
public class ShopifyOrderMarkerHostedService : EventHostedServiceBase
{
private readonly StoreRepository _storeRepository;
private readonly IHttpClientFactory _httpClientFactory;
public ShopifyOrderMarkerHostedService(EventAggregator eventAggregator,
StoreRepository storeRepository,
IHttpClientFactory httpClientFactory,
Logs logs) : base(eventAggregator, logs)
{
_storeRepository = storeRepository;
_httpClientFactory = httpClientFactory;
}
public const string SHOPIFY_ORDER_ID_PREFIX = "shopify-";
protected override void SubscribeToEvents()
{
Subscribe<InvoiceEvent>();
base.SubscribeToEvents();
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
if (evt is InvoiceEvent invoiceEvent && !new[]
{
InvoiceEvent.Created, InvoiceEvent.ExpiredPaidPartial,
InvoiceEvent.ReceivedPayment, InvoiceEvent.PaidInFull
}.Contains(invoiceEvent.Name))
{
var invoice = invoiceEvent.Invoice;
var shopifyOrderId = invoice.GetInternalTags(SHOPIFY_ORDER_ID_PREFIX).FirstOrDefault();
if (shopifyOrderId != null)
{
if (new[] { InvoiceStatusLegacy.Invalid, InvoiceStatusLegacy.Expired }.Contains(invoice.GetInvoiceState()
.Status) && invoice.ExceptionStatus != InvoiceExceptionStatus.None)
{
//you have failed us, customer
await RegisterTransaction(invoice, shopifyOrderId, false);
}
else if (new[] { InvoiceStatusLegacy.Complete, InvoiceStatusLegacy.Confirmed }.Contains(
invoice.Status))
{
await RegisterTransaction(invoice, shopifyOrderId, true);
}
}
}
await base.ProcessEvent(evt, cancellationToken);
}
private async Task RegisterTransaction(InvoiceEntity invoice, string shopifyOrderId, bool success)
{
var storeData = await _storeRepository.FindStore(invoice.StoreId);
var storeBlob = storeData.GetStoreBlob();
// ensure that store in question has shopify integration turned on
// and that invoice's orderId has shopify specific prefix
var settings = storeBlob.GetShopifySettings();
if (settings?.IntegratedAt.HasValue == true)
{
var client = CreateShopifyApiClient(settings);
if (!await client.OrderExists(shopifyOrderId))
{
// don't register transactions for orders that don't exist on shopify
return;
}
// if we got this far, we likely need to register this invoice's payment on Shopify
// OrderTransactionRegisterLogic has check if transaction is already registered which is why we're passing invoice.Id
try
{
var logic = new OrderTransactionRegisterLogic(client);
var resp = await logic.Process(shopifyOrderId, invoice.Id, invoice.Currency,
invoice.Price.ToString(CultureInfo.InvariantCulture), success);
if (resp != null)
{
Logs.PayServer.LogInformation($"Registered order transaction {invoice.Price}{invoice.Currency} on Shopify. " +
$"Triggered by invoiceId: {invoice.Id}, Shopify orderId: {shopifyOrderId}, Success: {success}");
}
}
catch (Exception ex)
{
Logs.PayServer.LogError(ex,
$"Shopify error while trying to register order transaction. " +
$"Triggered by invoiceId: {invoice.Id}, Shopify orderId: {shopifyOrderId}");
}
}
}
private ShopifyApiClient CreateShopifyApiClient(ShopifySettings shopify)
{
return new ShopifyApiClient(_httpClientFactory, shopify.CreateShopifyApiCredentials());
}
}
}

View File

@ -14,7 +14,8 @@ namespace BTCPayServer.Plugins.Shopify
public override void Execute(IServiceCollection applicationBuilder)
{
applicationBuilder.AddSingleton<IHostedService, ShopifyOrderMarkerHostedService>();
applicationBuilder.AddSingleton<ShopifyService>();
applicationBuilder.AddSingleton<IHostedService, ShopifyService>(provider => provider.GetRequiredService<ShopifyService>());
applicationBuilder.AddSingleton<IUIExtension>(new UIExtension("Shopify/NavExtension", "header-nav"));
base.Execute(applicationBuilder);
}

View File

@ -0,0 +1,234 @@
using System;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Plugins.Shopify.ApiModels;
using BTCPayServer.Plugins.Shopify.Models;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.Plugins.Shopify
{
public class ShopifyService : EventHostedServiceBase
{
private readonly StoreRepository _storeRepository;
private readonly InvoiceRepository _invoiceRepository;
private readonly IHttpClientFactory _httpClientFactory;
public ShopifyService(EventAggregator eventAggregator,
StoreRepository storeRepository,
InvoiceRepository invoiceRepository,
IHttpClientFactory httpClientFactory,
Logs logs) : base(eventAggregator, logs)
{
_storeRepository = storeRepository;
_invoiceRepository = invoiceRepository;
_httpClientFactory = httpClientFactory;
}
public const string SHOPIFY_ORDER_ID_PREFIX = "shopify-";
protected override void SubscribeToEvents()
{
Subscribe<InvoiceEvent>();
base.SubscribeToEvents();
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
if (evt is InvoiceEvent invoiceEvent && new[]
{
InvoiceEvent.MarkedCompleted,
InvoiceEvent.MarkedInvalid,
InvoiceEvent.Expired,
InvoiceEvent.Confirmed,
InvoiceEvent.Completed
}.Contains(invoiceEvent.Name))
{
var invoice = invoiceEvent.Invoice;
var shopifyOrderId = invoice.GetInternalTags(SHOPIFY_ORDER_ID_PREFIX).FirstOrDefault();
if (shopifyOrderId != null)
{
var success = invoice.Status.ToModernStatus() switch
{
InvoiceStatus.Settled => true,
InvoiceStatus.Invalid or InvoiceStatus.Expired => false,
_ => (bool?)null
};
if (success.HasValue)
await RegisterTransaction(invoice, shopifyOrderId, success.Value);
}
}
await base.ProcessEvent(evt, cancellationToken);
}
private async Task RegisterTransaction(InvoiceEntity invoice, string shopifyOrderId, bool success)
{
var storeData = await _storeRepository.FindStore(invoice.StoreId);
var storeBlob = storeData.GetStoreBlob();
// ensure that store in question has shopify integration turned on
// and that invoice's orderId has shopify specific prefix
var settings = storeBlob.GetShopifySettings();
if (settings?.IntegratedAt.HasValue == true)
{
var client = CreateShopifyApiClient(settings);
if (!await client.OrderExists(shopifyOrderId))
{
// don't register transactions for orders that don't exist on shopify
return;
}
// if we got this far, we likely need to register this invoice's payment on Shopify
// OrderTransactionRegisterLogic has check if transaction is already registered which is why we're passing invoice.Id
try
{
var resp = await Process(client, shopifyOrderId, invoice.Id, invoice.Currency,
invoice.Price.ToString(CultureInfo.InvariantCulture), success);
if (resp != null)
{
await _invoiceRepository.AddInvoiceLogs(invoice.Id, resp);
}
}
catch (Exception ex)
{
Logs.PayServer.LogError(ex,
$"Shopify error while trying to register order transaction. " +
$"Triggered by invoiceId: {invoice.Id}, Shopify orderId: {shopifyOrderId}");
}
}
}
private ShopifyApiClient CreateShopifyApiClient(ShopifySettings shopify)
{
return new ShopifyApiClient(_httpClientFactory, shopify.CreateShopifyApiCredentials());
}
private static string[] _keywords = new[] {"bitcoin", "btc", "btcpayserver", "btcpay server"};
public async Task<InvoiceLogs> Process(ShopifyApiClient client, string orderId, string invoiceId,
string currency, string amountCaptured, bool success)
{
var result = new InvoiceLogs();
currency = currency.ToUpperInvariant().Trim();
var existingShopifyOrderTransactions = (await client.TransactionsList(orderId)).transactions;
//if there isn't a record for btcpay payment gateway, abort
var baseParentTransaction = existingShopifyOrderTransactions.FirstOrDefault(holder =>
_keywords.Any(a => holder.gateway.Contains(a, StringComparison.InvariantCultureIgnoreCase)));
if (baseParentTransaction is null)
{
result.Write("Couldn't find the order on Shopify.", InvoiceEventData.EventSeverity.Error);
return result;
}
//technically, this exploit should not be possible as we use internal invoice tags to verify that the invoice was created by our controlled, dedicated endpoint.
if (currency.ToUpperInvariant().Trim() != baseParentTransaction.currency.ToUpperInvariant().Trim())
{
// because of parent_id present, currency will always be the one from parent transaction
// malicious attacker could potentially exploit this by creating invoice
// in different currency and paying that one, registering order on Shopify as paid
// so if currency is supplied and is different from parent transaction currency we just won't register
result.Write("Currency mismatch on Shopify.", InvoiceEventData.EventSeverity.Error);
return result;
}
var kind = "capture";
var parentId = baseParentTransaction.id;
var status = success ? "success" : "failure";
//find all existing transactions recorded around this invoice id
var existingShopifyOrderTransactionsOnSameInvoice =
existingShopifyOrderTransactions.Where(holder => holder.authorization == invoiceId);
//filter out the successful ones
var successfulActions =
existingShopifyOrderTransactionsOnSameInvoice.Where(holder => holder.status == "success").ToArray();
//of the successful ones, get the ones we registered as a valid payment
var successfulCaptures = successfulActions.Where(holder => holder.kind == "capture").ToArray();
//of the successful ones, get the ones we registered as a voiding of a previous successful payment
var refunds = successfulActions.Where(holder => holder.kind == "refund").ToArray();
//if we are working with a non-success registration, but see that we have previously registered this invoice as a success, we switch to creating a "void" transaction, which in shopify terms is a refund.
if (!success && successfulCaptures.Length > 0 && (successfulCaptures.Length - refunds.Length) > 0)
{
kind = "void";
parentId = successfulCaptures.Last().id;
status = "success";
result.Write(
"A transaction was previously recorded against the Shopify order. Creating a void transaction.",
InvoiceEventData.EventSeverity.Warning);
}
else if (!success)
{
kind = "void";
status = "success";
result.Write("Attempting to void the payment on Shopify order due to failure in payment.",
InvoiceEventData.EventSeverity.Warning);
}
//if we are working with a success registration, but can see that we have already had a successful transaction saved, get outta here
else if (success && successfulCaptures.Length > 0 && (successfulCaptures.Length - refunds.Length) > 0)
{
result.Write("A transaction was previously recorded against the Shopify order. Skipping.",
InvoiceEventData.EventSeverity.Warning);
return result;
}
var createTransaction = new TransactionsCreateReq
{
transaction = new TransactionsCreateReq.DataHolder
{
parent_id = parentId,
currency = currency,
amount = amountCaptured,
kind = kind,
gateway = "BTCPayServer",
source = "external",
authorization = invoiceId,
status = status
}
};
var createResp = await client.TransactionCreate(orderId, createTransaction);
if (createResp.transaction is null)
{
result.Write("Failed to register the transaction on Shopify.", InvoiceEventData.EventSeverity.Error);
}
else
{
result.Write(
$"Successfully registered the transaction on Shopify. tx status:{createResp.transaction.status}, kind: {createResp.transaction.kind}, order id:{createResp.transaction.order_id}",
InvoiceEventData.EventSeverity.Info);
}
if (!success)
{
try
{
await client.CancelOrder(orderId);
result.Write("Cancelling the Shopify order.", InvoiceEventData.EventSeverity.Warning);
}
catch (Exception e)
{
result.Write($"Failed to cancel the Shopify order. {e.Message}",
InvoiceEventData.EventSeverity.Error);
}
}
return result;
}
}
}

View File

@ -34,6 +34,7 @@ namespace BTCPayServer.Plugins.Shopify
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public class UIShopifyController : Controller
{
private readonly ShopifyService _shopifyService;
private readonly BTCPayServerEnvironment _btcPayServerEnvironment;
private readonly IOptions<BTCPayServerOptions> _btcPayServerOptions;
private readonly IWebHostEnvironment _webHostEnvironment;
@ -43,7 +44,9 @@ namespace BTCPayServer.Plugins.Shopify
private readonly IJsonHelper _jsonHelper;
private readonly IHttpClientFactory _clientFactory;
public UIShopifyController(BTCPayServerEnvironment btcPayServerEnvironment,
public UIShopifyController(
ShopifyService shopifyService,
BTCPayServerEnvironment btcPayServerEnvironment,
IOptions<BTCPayServerOptions> btcPayServerOptions,
IWebHostEnvironment webHostEnvironment,
StoreRepository storeRepository,
@ -52,6 +55,7 @@ namespace BTCPayServer.Plugins.Shopify
IJsonHelper jsonHelper,
IHttpClientFactory clientFactory)
{
_shopifyService = shopifyService;
_btcPayServerEnvironment = btcPayServerEnvironment;
_btcPayServerOptions = btcPayServerOptions;
_webHostEnvironment = webHostEnvironment;
@ -106,14 +110,14 @@ namespace BTCPayServer.Plugins.Shopify
public async Task<IActionResult> ShopifyInvoiceEndpoint(
string storeId, string orderId, decimal amount, bool checkOnly = false)
{
var shopifySearchTerm = $"{ShopifyOrderMarkerHostedService.SHOPIFY_ORDER_ID_PREFIX}{orderId}";
var shopifySearchTerm = $"{ShopifyService.SHOPIFY_ORDER_ID_PREFIX}{orderId}";
var matchedExistingInvoices = await _invoiceRepository.GetInvoices(new InvoiceQuery()
{
TextSearch = shopifySearchTerm,
StoreId = new[] { storeId }
});
matchedExistingInvoices = matchedExistingInvoices.Where(entity =>
entity.GetInternalTags(ShopifyOrderMarkerHostedService.SHOPIFY_ORDER_ID_PREFIX)
entity.GetInternalTags(ShopifyService.SHOPIFY_ORDER_ID_PREFIX)
.Any(s => s == orderId))
.ToArray();
@ -155,7 +159,7 @@ namespace BTCPayServer.Plugins.Shopify
if (client != null && order?.FinancialStatus == "pending" &&
firstInvoiceSettled.Status != InvoiceStatusLegacy.Paid)
{
await new OrderTransactionRegisterLogic(client).Process(orderId, firstInvoiceSettled.Id,
await _shopifyService.Process(client, orderId, firstInvoiceSettled.Id,
firstInvoiceSettled.Currency,
firstInvoiceSettled.Price.ToString(CultureInfo.InvariantCulture), true);
order = await client.GetOrder(orderId);

View File

@ -70,6 +70,47 @@
},
"applicationUrl": "https://localhost:14142/"
},
"Altcoins": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"BTCPAY_EXPERIMENTALV2_CONFIRM": "true",
"BTCPAY_NETWORK": "regtest",
"BTCPAY_LAUNCHSETTINGS": "true",
"BTCPAY_PORT": "14142",
"BTCPAY_HttpsUseDefaultCertificate": "true",
"BTCPAY_VERBOSE": "true",
"BTCPAY_LTCEXPLORERURL": "http://127.0.0.1:32838/",
"BTCPAY_LBTCEXPLORERURL": "http://127.0.0.1:32838/",
"BTCPAY_BTCLIGHTNING": "type=clightning;server=tcp://127.0.0.1:30993/",
"BTCPAY_BTCEXTERNALLNDREST": "type=lnd-rest;server=http://lnd:lnd@127.0.0.1:35531/;allowinsecure=true",
"BTCPAY_BTCEXTERNALLNDSEEDBACKUP": "../BTCPayServer.Tests/TestData/LndSeedBackup/walletunlock.json",
"BTCPAY_BTCEXTERNALSPARK": "server=/spark/btc/;cookiefile=fake",
"BTCPAY_BTCEXTERNALCHARGE": "server=https://127.0.0.1:53280/mycharge/btc/;cookiefilepath=fake",
"BTCPAY_BTCEXTERNALRTL": "server=/rtl/api/authenticate/cookie;cookiefile=fake",
"BTCPAY_BTCEXTERNALTHUNDERHUB": "server=/thub/sso;cookiefile=fake",
"BTCPAY_BTCEXTERNALTORQ": "server=/torq/cookie-login;cookiefile=fake",
"BTCPAY_EXTERNALSERVICES": "totoservice:totolink;Lightning Terminal:/lit/;",
"BTCPAY_EXTERNALCONFIGURATOR": "passwordfile=testpwd;server=/configurator",
"BTCPAY_BTCEXPLORERURL": "http://127.0.0.1:32838/",
"BTCPAY_ALLOW-ADMIN-REGISTRATION": "true",
"BTCPAY_DISABLE-REGISTRATION": "false",
"ASPNETCORE_ENVIRONMENT": "Development",
"BTCPAY_CHAINS": "btc,ltc,lbtc,xmr",
"BTCPAY_POSTGRES": "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=btcpayserver",
"BTCPAY_DEBUGLOG": "debug.log",
"BTCPAY_TORRCFILE": "../BTCPayServer.Tests/TestData/Tor/torrc",
"BTCPAY_SOCKSENDPOINT": "localhost:9050",
"BTCPAY_DOCKERDEPLOYMENT": "true",
"BTCPAY_RECOMMENDED-PLUGINS": "",
"BTCPAY_CHEATMODE": "true",
"BTCPAY_EXPLORERPOSTGRES": "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=nbxplorer",
"BTCPAY_XMR_DAEMON_URI": "http://127.0.0.1:18081",
"BTCPAY_XMR_WALLET_DAEMON_URI": "http://127.0.0.1:18082",
"BTCPAY_XMR_WALLET_DAEMON_WALLETDIR": "/path/to/monero_wallet"
},
"applicationUrl": "https://localhost:14142/"
},
"Altcoins-HTTPS": {
"commandName": "Project",
"launchBrowser": true,
@ -95,7 +136,7 @@
"BTCPAY_ALLOW-ADMIN-REGISTRATION": "true",
"BTCPAY_DISABLE-REGISTRATION": "false",
"ASPNETCORE_ENVIRONMENT": "Development",
"BTCPAY_CHAINS": "btc,ltc,lbtc",
"BTCPAY_CHAINS": "btc,ltc,lbtc,xmr",
"BTCPAY_POSTGRES": "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=btcpayserver",
"BTCPAY_SSHCONNECTION": "root@127.0.0.1:21622",
"BTCPAY_SSHPASSWORD": "opD3i2282D",
@ -105,7 +146,10 @@
"BTCPAY_DOCKERDEPLOYMENT": "true",
"BTCPAY_RECOMMENDED-PLUGINS": "",
"BTCPAY_CHEATMODE": "true",
"BTCPAY_EXPLORERPOSTGRES": "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=nbxplorer"
"BTCPAY_EXPLORERPOSTGRES": "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=39372;Database=nbxplorer",
"BTCPAY_XMR_DAEMON_URI": "http://127.0.0.1:18081",
"BTCPAY_XMR_WALLET_DAEMON_URI": "http://127.0.0.1:18082",
"BTCPAY_XMR_WALLET_DAEMON_WALLETDIR": "/path/to/monero_wallet"
},
"applicationUrl": "https://localhost:14142/"
}

View File

@ -9,7 +9,8 @@ namespace BTCPayServer
{
private const char FilterSeparator = ',';
private const char ValueSeparator = ':';
private static readonly string[] StripFilters = ["status", "exceptionstatus", "unusual", "includearchived", "appid", "startdate", "enddate"];
private readonly string _originalString;
private readonly int _timezoneOffset;
@ -27,12 +28,18 @@ namespace BTCPayServer
.Where(kv => kv.Length == 2)
.Select(kv => new KeyValuePair<string, string>(UnifyKey(kv[0]), kv[1]))
.ToMultiValueDictionary(o => o.Key, o => o.Value);
var val = splitted.FirstOrDefault(a => a.IndexOf(ValueSeparator, StringComparison.OrdinalIgnoreCase) == -1);
TextSearch = val != null ? val.Trim() : string.Empty;
// combine raw search term and filters which don't have a special UI (e.g. orderid)
var textFilters = Filters
.Where(f => !StripFilters.Contains(f.Key))
.Select(f => string.Join(FilterSeparator, f.Value.Select(v => $"{f.Key}{ValueSeparator}{v}"))).ToList();
TextFilters = textFilters.Any() ? string.Join(FilterSeparator, textFilters) : null;
TextSearch = splitted.FirstOrDefault(a => a.IndexOf(ValueSeparator, StringComparison.OrdinalIgnoreCase) == -1)?.Trim();
}
public string TextSearch { get; private set; }
public string TextFilters { get; private set; }
public string TextCombined => string.Join(FilterSeparator, new []{ TextFilters, TextSearch }.Where(x => !string.IsNullOrEmpty(x)));
public MultiValueDictionary<string, string> Filters { get; }
@ -82,9 +89,10 @@ namespace BTCPayServer
public string WithoutSearchText()
{
return string.IsNullOrEmpty(TextSearch)
? Finalize(ToString())
: Finalize(ToString()).Replace(TextSearch, string.Empty);
var txt = ToString();
if (!string.IsNullOrEmpty(TextSearch)) txt = Finalize(txt.Replace(TextSearch, string.Empty));
if (!string.IsNullOrEmpty(TextFilters)) txt = Finalize(txt.Replace(TextFilters, string.Empty));
return Finalize(txt).Trim();
}
public string[] GetFilterArray(string key)
@ -144,7 +152,7 @@ namespace BTCPayServer
private static string Finalize(string str)
{
var value = str.TrimStart(FilterSeparator).TrimEnd(FilterSeparator);
var value = str.Trim().TrimStart(FilterSeparator).TrimEnd(FilterSeparator);
return string.IsNullOrEmpty(value) ? " " : value;
}
}

View File

@ -59,12 +59,12 @@ namespace BTCPayServer.Services.Altcoins.Monero.Services
try
{
var daemonResult =
await daemonRpcClient.SendCommandAsync<JsonRpcClient.NoRequestModel, SyncInfoResponse>("sync_info",
await daemonRpcClient.SendCommandAsync<JsonRpcClient.NoRequestModel, GetInfoResponse>("get_info",
JsonRpcClient.NoRequestModel.Instance);
summary.TargetHeight = daemonResult.TargetHeight.GetValueOrDefault(0);
summary.CurrentHeight = daemonResult.Height;
summary.TargetHeight = summary.TargetHeight == 0 ? summary.CurrentHeight : summary.TargetHeight;
summary.Synced = daemonResult.Height >= summary.TargetHeight && summary.CurrentHeight > 0;
summary.Synced = !daemonResult.BusySyncing;
summary.UpdatedAt = DateTime.UtcNow;
summary.DaemonAvailable = true;
}

View File

@ -104,14 +104,19 @@ namespace BTCPayServer.Services.Altcoins.Monero.UI
new SelectListItem(
$"{account.AccountIndex} - {(string.IsNullOrEmpty(account.Label) ? "No label" : account.Label)}",
account.AccountIndex.ToString(CultureInfo.InvariantCulture)));
var settlementThresholdChoice = settings.InvoiceSettledConfirmationThreshold switch
var settlementThresholdChoice = MoneroLikeSettlementThresholdChoice.StoreSpeedPolicy;
if (settings != null && settings.InvoiceSettledConfirmationThreshold is { } confirmations)
{
null => MoneroLikeSettlementThresholdChoice.StoreSpeedPolicy,
0 => MoneroLikeSettlementThresholdChoice.ZeroConfirmation,
1 => MoneroLikeSettlementThresholdChoice.AtLeastOne,
10 => MoneroLikeSettlementThresholdChoice.AtLeastTen,
_ => MoneroLikeSettlementThresholdChoice.Custom
};
settlementThresholdChoice = confirmations switch
{
0 => MoneroLikeSettlementThresholdChoice.ZeroConfirmation,
1 => MoneroLikeSettlementThresholdChoice.AtLeastOne,
10 => MoneroLikeSettlementThresholdChoice.AtLeastTen,
_ => MoneroLikeSettlementThresholdChoice.Custom
};
}
return new MoneroLikePaymentMethodViewModel()
{
WalletFileFound = System.IO.File.Exists(fileAddress),
@ -124,9 +129,11 @@ namespace BTCPayServer.Services.Altcoins.Monero.UI
Accounts = accounts == null ? null : new SelectList(accounts, nameof(SelectListItem.Value),
nameof(SelectListItem.Text)),
SettlementConfirmationThresholdChoice = settlementThresholdChoice,
CustomSettlementConfirmationThreshold = settlementThresholdChoice is MoneroLikeSettlementThresholdChoice.Custom
? settings.InvoiceSettledConfirmationThreshold
: null
CustomSettlementConfirmationThreshold =
settings != null &&
settlementThresholdChoice is MoneroLikeSettlementThresholdChoice.Custom
? settings.InvoiceSettledConfirmationThreshold
: null
};
}
@ -242,10 +249,28 @@ namespace BTCPayServer.Services.Altcoins.Monero.UI
}
}
try
{
var response = await _MoneroRpcProvider.WalletRpcClients[cryptoCode].SendCommandAsync<OpenWalletRequest, OpenWalletResponse>("open_wallet", new OpenWalletRequest
{
Filename = "wallet",
Password = viewModel.WalletPassword
});
if (response?.Error != null)
{
throw new Exception(response.Error.Message);
}
}
catch (Exception ex)
{
ModelState.AddModelError(nameof(viewModel.AccountIndex), $"Could not open the wallet: {ex.Message}");
return View(viewModel);
}
return RedirectToAction(nameof(GetStoreMoneroLikePaymentMethod), new
{
cryptoCode,
StatusMessage = "View-only wallet files uploaded. If they are valid the wallet will soon become available."
StatusMessage = "View-only wallet files uploaded. The wallet will soon become available."
});
}

View File

@ -0,0 +1,133 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using Dapper;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Services.Reporting
{
public class RefundsReportProvider : ReportProvider
{
private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings;
private readonly DisplayFormatter _displayFormatter;
private ViewDefinition CreateDefinition()
{
return new ViewDefinition
{
Fields = new List<StoreReportResponse.Field>
{
new("Date", "datetime"),
new("InvoiceId", "invoice_id"),
new("Currency", "string"),
new("Completed", "amount"),
new("Awaiting", "amount"),
new("Limit", "amount"),
new("FullyPaid", "boolean")
},
Charts =
{
new ()
{
Name = "Aggregated amount",
Groups = { "Currency" },
HasGrandTotal = false,
Aggregates = { "Awaiting", "Completed", "Limit" }
}
}
};
}
public override string Name => "Refunds";
public ApplicationDbContextFactory DbContextFactory { get; }
public RefundsReportProvider(
ApplicationDbContextFactory dbContextFactory,
BTCPayNetworkJsonSerializerSettings serializerSettings,
DisplayFormatter displayFormatter)
{
DbContextFactory = dbContextFactory;
_serializerSettings = serializerSettings;
_displayFormatter = displayFormatter;
}
record RefundRow(DateTimeOffset Created, string InvoiceId, string PullPaymentId, string Currency, decimal Limit)
{
public decimal Completed { get; set; }
public decimal Awaiting { get; set; }
}
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
{
queryContext.ViewDefinition = CreateDefinition();
RefundRow? currentRow = null;
await using var ctx = DbContextFactory.CreateContext();
var conn = ctx.Database.GetDbConnection();
var rows = await conn.QueryAsync(
"""
SELECT i."Created", i."Id" AS "InvoiceId", p."State", p."PaymentMethodId", pp."Id" AS "PullPaymentId", pp."Blob" AS "ppBlob", p."Blob" AS "pBlob" FROM "Invoices" i
JOIN "Refunds" r ON r."InvoiceDataId"= i."Id"
JOIN "PullPayments" pp ON r."PullPaymentDataId"=pp."Id"
LEFT JOIN "Payouts" p ON p."PullPaymentDataId"=pp."Id"
WHERE i."StoreDataId" = @storeId
AND i."Created" >= @start AND i."Created" <= @end
AND pp."Archived" IS FALSE
ORDER BY i."Created", pp."Id"
""", new { start = queryContext.From, end = queryContext.To, storeId = queryContext.StoreId });
foreach (var r in rows)
{
PullPaymentBlob ppBlob = GetPullPaymentBlob(r);
PayoutBlob? pBlob = GetPayoutBlob(r);
if ((string)r.PullPaymentId != currentRow?.PullPaymentId)
{
AddRow(queryContext, currentRow);
currentRow = new(r.Created, r.InvoiceId, r.PullPaymentId, ppBlob.Currency, ppBlob.Limit);
}
if (pBlob is null)
continue;
var state = Enum.Parse<PayoutState>((string)r.State);
if (state == PayoutState.Cancelled)
continue;
if (state is PayoutState.Completed)
currentRow.Completed += pBlob.Amount;
else
currentRow.Awaiting += pBlob.Amount;
}
AddRow(queryContext, currentRow);
}
private PayoutBlob? GetPayoutBlob(dynamic r)
{
if (r.pBlob is null)
return null;
Data.PayoutData p = new Data.PayoutData();
p.PaymentMethodId = r.PaymentMethodId;
p.Blob = (string)r.pBlob;
return p.GetBlob(_serializerSettings);
}
private static PullPaymentBlob GetPullPaymentBlob(dynamic r)
{
Data.PullPaymentData pp = new Data.PullPaymentData();
pp.Blob = (string)r.ppBlob;
return pp.GetBlob();
}
private void AddRow(QueryContext queryContext, RefundRow? currentRow)
{
if (currentRow is null)
return;
var data = queryContext.AddData();
data.Add(currentRow.Created);
data.Add(currentRow.InvoiceId);
data.Add(currentRow.Currency);
data.Add(_displayFormatter.ToFormattedAmount(currentRow.Completed, currentRow.Currency));
data.Add(_displayFormatter.ToFormattedAmount(currentRow.Awaiting, currentRow.Currency));
data.Add(_displayFormatter.ToFormattedAmount(currentRow.Limit, currentRow.Currency));
data.Add(currentRow.Limit <= currentRow.Completed);
}
}
}

View File

@ -29,7 +29,7 @@ public class TransactionLinkProviders : Dictionary<PaymentMethodId, TransactionL
{
foreach ((var pmi, var prov) in this)
{
var overrideLink = links.SingleOrDefault(item =>
var overrideLink = links.FirstOrDefault(item =>
item.CryptoCode.Equals(pmi.CryptoCode, StringComparison.InvariantCultureIgnoreCase) ||
item.CryptoCode.Equals(pmi.ToString(), StringComparison.InvariantCultureIgnoreCase));
prov.OverrideBlockExplorerLink = overrideLink?.Link ?? prov.BlockExplorerLinkDefault;

View File

@ -91,7 +91,7 @@ namespace BTCPayServer.Services.Wallets.Export
{
using StringWriter writer = new();
using var csvWriter = new CsvHelper.CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture), true);
csvWriter.Configuration.RegisterClassMap<ExportTransactionMap>();
csvWriter.Context.RegisterClassMap<ExportTransactionMap>();
csvWriter.WriteHeader<ExportTransaction>();
csvWriter.NextRecord();
csvWriter.WriteRecords(invoices);
@ -105,7 +105,7 @@ namespace BTCPayServer.Services.Wallets.Export
public ExportTransactionMap()
{
AutoMap(CultureInfo.InvariantCulture);
Map(m => m.Labels).ConvertUsing(row => row.Labels == null ? string.Empty : string.Join(", ", row.Labels));
Map(m => m.Labels).Convert(row => row.Value.Labels == null ? string.Empty : string.Join(", ", row.Value.Labels));
}
}

View File

@ -326,7 +326,7 @@
<div class="accordion-body">
<div class="form-group">
<label asp-for="CustomCSSLink" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/#2-bootstrap-themes" target="_blank" rel="noreferrer noopener" title="More information...">
<a href="https://docs.btcpayserver.org/Development/Theme/" target="_blank" rel="noreferrer noopener" title="More information...">
<vc:icon symbol="info" />
</a>
<input asp-for="CustomCSSLink" class="form-control" />

View File

@ -1,6 +1,7 @@
@using BTCPayServer.Client
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Models.ServerViewModels
@model BTCPayServer.Models.EmailsViewModel
<div class="row">
@ -44,7 +45,7 @@
<div class="form-text">For many email providers (like Gmail) your login is your email address.</div>
<span asp-validation-for="Settings.Login" class="text-danger"></span>
</div>
<div class="form-group" permission="@Policies.CanModifyStoreSettings">
<div class="form-group" permission="@(Model is ServerEmailsViewModel ? Policies.CanModifyServerSettings : Policies.CanModifyStoreSettings)">
@if (!Model.PasswordSet)
{
<label asp-for="Settings.Password" class="form-label"></label>
@ -81,7 +82,6 @@
<script>
document.addEventListener("DOMContentLoaded", function () {
delegate('click', '#quick-fill .dropdown-menu a', function (e) {
e.preventDefault();

View File

@ -72,6 +72,7 @@ else
{
var item = Model.Items[x];
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed && item.Price == 0) continue;
<div class="d-flex flex-wrap">
<div class="card px-0" data-id="@x">
<div class="card-body p-3 d-flex flex-column gap-2">

View File

@ -304,7 +304,7 @@
<div class="accordion-body">
<div class="form-group">
<label asp-for="CustomCSSLink" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/#2-bootstrap-themes" target="_blank" rel="noreferrer noopener" title="More information...">
<a href="https://docs.btcpayserver.org/Development/Theme/" target="_blank" rel="noreferrer noopener" title="More information...">
<vc:icon symbol="info" />
</a>
<input asp-for="CustomCSSLink" class="form-control" />

View File

@ -1,3 +1,4 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model (Dictionary<string, object> Items, int Level)
@functions {
@ -10,14 +11,20 @@
@if (Model.Items.Any())
{
var hasCart = Model.Items.ContainsKey("Cart");
@* Use titlecase and lowercase versions for backwards-compatibility *@
string[] cartKeys = ["cart", "subtotal", "discount", "tip", "total"];
<table class="table my-0" v-pre>
@if (hasCart || (Model.Items.ContainsKey("Subtotal") && Model.Items.ContainsKey("Total")))
@if (Model.Items.Keys.Any(key => cartKeys.Contains(key.ToLowerInvariant())))
{
@if (hasCart)
_ = Model.Items.TryGetValue("cart", out var cart) || Model.Items.TryGetValue("Cart", out cart);
var hasTotal = Model.Items.TryGetValue("total", out var total) || Model.Items.TryGetValue("Total", out total);
var hasSubtotal = Model.Items.TryGetValue("subtotal", out var subtotal) || Model.Items.TryGetValue("subTotal", out subtotal) || Model.Items.TryGetValue("Subtotal", out subtotal);
var hasDiscount = Model.Items.TryGetValue("discount", out var discount) || Model.Items.TryGetValue("Discount", out discount);
var hasTip = Model.Items.TryGetValue("tip", out var tip) || Model.Items.TryGetValue("Tip", out tip);
if (cart is Dictionary<string, object> { Keys.Count: > 0 } cartDict)
{
<tbody>
@foreach (var (key, value) in (Dictionary<string, object>)Model.Items["Cart"])
@foreach (var (key, value) in cartDict)
{
<tr>
<th>@key</th>
@ -26,35 +33,46 @@
}
</tbody>
}
<tfoot style="border-top-width:@(hasCart ? "3px" : "0")">
@if (Model.Items.ContainsKey("Subtotal"))
else if (cart is ICollection<object> { Count: > 0 } cartCollection)
{
<tbody>
@foreach (var value in cartCollection)
{
<tr>
<th>Subtotal</th>
<td class="text-end">@Model.Items["Subtotal"]</td>
</tr>
}
@if (Model.Items.ContainsKey("Discount"))
{
<tr>
<th>Discount</th>
<td class="text-end">@Model.Items["Discount"]</td>
</tr>
}
@if (Model.Items.ContainsKey("Tip"))
{
<tr>
<th>Tip</th>
<td class="text-end">@Model.Items["Tip"]</td>
</tr>
}
@if (Model.Items.ContainsKey("Total"))
{
<tr style="border-top-width:3px">
<th>Total</th>
<td class="text-end">@Model.Items["Total"]</td>
<td>@value</td>
</tr>
}
</tbody>
}
<tfoot style="border-top-width:0">
@if (hasSubtotal && (hasDiscount || hasTip))
{
<tr style="border-top-width:3px">
<th>Subtotal</th>
<td class="text-end">@subtotal</td>
</tr>
}
@if (hasDiscount)
{
<tr>
<th>Discount</th>
<td class="text-end">@discount</td>
</tr>
}
@if (hasTip)
{
<tr>
<th>Tip</th>
<td class="text-end">@tip</td>
</tr>
}
@if (hasTotal)
{
<tr style="border-top-width:3px">
<th>Total</th>
<td class="text-end">@total</td>
</tr>
}
</tfoot>
}
else

File diff suppressed because one or more lines are too long

View File

@ -18,7 +18,10 @@
@RenderBody()
</div>
</section>
<partial name="_Footer"/>
@if (ViewData["ShowFooter"] is not false)
{
<partial name="_Footer"/>
}
<partial name="LayoutFoot" />
@await RenderSectionAsync("PageFootContent", false)
</body>

View File

@ -52,6 +52,7 @@
</button>
</nav>
<section id="payment" v-if="isActive">
<div v-if="srvModel.itemDesc && srvModel.itemDesc !== srvModel.storeName" v-text="srvModel.itemDesc" class="fw-semibold text-center text-muted mb-3"></div>
<div class="d-flex justify-content-center mt-1 text-center">
@if (Model.IsUnsetTopUp)
{

View File

@ -431,7 +431,7 @@
</table>
</div>
}
@if (Model.ReceiptData != null && Model.ReceiptData.Any())
@if (Model.ReceiptData?.Any() is true)
{
<div>
<h3 class="mb-3">
@ -443,7 +443,7 @@
<partial name="PosData" model="(Model.ReceiptData, 1)" />
</div>
}
@if (Model.AdditionalData != null && Model.AdditionalData.Any())
@if (Model.AdditionalData?.Any() is true)
{
<div>
<h3 class="mb-3">

View File

@ -35,8 +35,8 @@
#InvoiceSummary { gap: var(--btcpay-space-l); }
#PaymentDetails table tbody tr:first-child td { padding-top: 1rem; }
#PaymentDetails table tbody:not(:last-child) tr:last-child > th,td { padding-bottom: 1rem; }
#posData td > table:last-child { margin-bottom: 0 !important; }
#posData table > tbody > tr:first-child > td > h4 { margin-top: 0 !important; }
#AdditionalData td > table:last-child, #CartData td > table:last-child { margin-bottom: 0 !important; }
#AdditionalData table > tbody > tr:first-child > td > h4, #CartData table > tbody > tr:first-child > td > h4 { margin-top: 0 !important; }
</style>
</head>
<body class="min-vh-100">
@ -62,7 +62,7 @@
{
if (Model.ReceiptOptions.ShowQR is true)
{
<vc:qr-code data="@Context.Request.GetCurrentUrl()"></vc:qr-code>
<vc:qr-code data="@Context.Request.GetCurrentUrl()" />
}
<div class="d-flex gap-4 mb-0 flex-fill">
<dl class="d-flex flex-column gap-4 mb-0 flex-fill">
@ -82,7 +82,7 @@
</div>
}
</dl>
<a href="?print=true" class="flex-grow-0 align-self-start btn btn-secondary d-print-none fs-4" target="_blank">Print</a>
<a href="?print=true" class="flex-grow-0 align-self-start btn btn-secondary d-print-none fs-4" target="_blank" id="ReceiptLinkPrint">Print</a>
</div>
}
</div>
@ -102,6 +102,15 @@
</div>
</div>
}
if (Model.CartData?.Any() is true)
{
<div id="CartData" class="tile">
<h2 class="h4 mb-3">Cart</h2>
<div class="table-responsive my-0">
<partial name="PosData" model="(Model.CartData, 1)" />
</div>
</div>
}
if (Model.Payments?.Any() is true)
{
<div id="PaymentDetails" class="tile">
@ -164,9 +173,9 @@
</div>
}
}
@if (!string.IsNullOrEmpty(Model.OrderUrl))
@if (!string.IsNullOrEmpty(Model.RedirectUrl))
{
<a href="@Model.OrderUrl" class="btn btn-secondary rounded-pill mx-auto mt-3" rel="noreferrer noopener" target="_blank">Return to @(string.IsNullOrEmpty(Model.StoreName) ? "store" : Model.StoreName)</a>
<a href="@Model.RedirectUrl" class="btn btn-secondary rounded-pill mx-auto mt-3" rel="noreferrer noopener" target="_blank">Return to @(string.IsNullOrEmpty(Model.StoreName) ? "store" : Model.StoreName)</a>
}
</div>
</main>

View File

@ -1,4 +1,17 @@
@model BTCPayServer.Models.InvoicingModels.InvoiceReceiptViewModel
@functions {
public bool IsManualEntryCart(Dictionary<string, object> additionalData)
{
_ = additionalData.TryGetValue("cart", out var data) || additionalData.TryGetValue("Cart", out data);
if (data is Dictionary<string, object> cart)
{
return cart.Count == 1 && cart.ContainsKey("Manual entry 1");
}
return false;
}
}
@using BTCPayServer.Client.Models
@using BTCPayServer.Components.QRCode
@using BTCPayServer.Services
@ -90,6 +103,7 @@
}
else
{
var hasCart = Model.CartData?.Any() is true;
<div id="PaymentDetails">
<div class="my-2 text-center small">
@if (!string.IsNullOrEmpty(Model.OrderId))
@ -99,53 +113,92 @@
@Model.Timestamp.ToBrowserDate()
</div>
<table class="table table-borderless table-sm small my-0">
<tr>
<td class="text-nowrap text-secondary">Total</td>
<td class="text-end fw-semibold">@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</td>
</tr>
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
@if (Model.AdditionalData?.Any() is true &&
(Model.AdditionalData.ContainsKey("Cart") || Model.AdditionalData.ContainsKey("Discount") || Model.AdditionalData.ContainsKey("Tip")))
@if (Model.AdditionalData?.Any() is true)
{
@if (Model.AdditionalData.ContainsKey("Cart"))
@foreach (var (key, value) in Model.AdditionalData)
{
@foreach (var (key, value) in (Dictionary<string, object>)Model.AdditionalData["Cart"])
{
<tr>
<td class="text-secondary">@key</td>
<td class="text-end">@value</td>
</tr>
}
}
@if (Model.AdditionalData.ContainsKey("Subtotal"))
{
<tr>
<td class="text-secondary">Subtotal</td>
<td class="text-end">@Model.AdditionalData["Subtotal"]</td>
</tr>
}
@if (Model.AdditionalData.ContainsKey("Discount"))
{
<tr>
<td class="text-secondary">Discount</td>
<td class="text-end">@Model.AdditionalData["Discount"]</td>
</tr>
}
@if (Model.AdditionalData.ContainsKey("Tip"))
{
<tr>
<td class="text-secondary">Tip</td>
<td class="text-end">@Model.AdditionalData["Tip"]</td>
<tr class="additional-data">
<td class="text-secondary">@key</td>
<td class="text-end">@value</td>
</tr>
}
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
}
@if (hasCart && !IsManualEntryCart(Model.CartData))
{
_ = Model.CartData.TryGetValue("cart", out var cart) || Model.CartData.TryGetValue("Cart", out cart);
var hasTotal = Model.CartData.TryGetValue("total", out var total) || Model.CartData.TryGetValue("Total", out total);
var hasSubtotal = Model.CartData.TryGetValue("subtotal", out var subtotal) || Model.CartData.TryGetValue("subTotal", out subtotal) || Model.CartData.TryGetValue("Subtotal", out subtotal);
var hasDiscount = Model.CartData.TryGetValue("discount", out var discount) || Model.CartData.TryGetValue("Discount", out discount);
var hasTip = Model.CartData.TryGetValue("tip", out var tip) || Model.CartData.TryGetValue("Tip", out tip);
if (cart is Dictionary<string, object> { Keys.Count: > 0 } cartDict)
{
@foreach (var (key, value) in cartDict)
{
<tr class="cart-data">
<td class="key text-secondary">@key</td>
<td class="val text-end">@value</td>
</tr>
}
}
else if (cart is ICollection<object> { Count: > 0 } cartCollection)
{
@foreach (var value in cartCollection)
{
<tr>
<td class="val text-end">@value</td>
</tr>
}
}
if (hasSubtotal && (hasDiscount || hasTip))
{
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
<tr class="sums-data">
<td class="key text-secondary">Subtotal</td>
<td class="val text-end">@subtotal</td>
</tr>
}
if (hasDiscount)
{
<tr class="sums-data">
<td class="key text-secondary">Discount</td>
<td class="val text-end">@discount</td>
</tr>
}
if (hasTip)
{
<tr class="sums-data">
<td class="key text-secondary">Tip</td>
<td class="val text-end">@tip</td>
</tr>
}
if (hasTotal)
{
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
<tr class="sums-data">
<td class="key text-secondary">Total</td>
<td class="val text-end fw-semibold">@total</td>
</tr>
}
}
else
{
<tr class="sums-data">
<td class="key text-nowrap text-secondary">Total</td>
<td class="val text-end fw-semibold">@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</td>
</tr>
}
@if (Model.Payments?.Any() is true)
{
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
@for (var i = 0; i < Model.Payments.Count; i++)
{
var payment = Model.Payments[i];
@ -154,33 +207,33 @@
<tr>
<td colspan="2" class="text-nowrap text-secondary">Payment @(i + 1)</td>
</tr>
<tr>
<tr class="payment-data">
<td class="text-nowrap">Received</td>
<td>@payment.ReceivedDate.ToBrowserDate()</td>
</tr>
}
<tr>
<tr class="payment-data">
<td class="text-nowrap text-secondary">@(Model.Payments.Count == 1 ? "Paid" : "")</td>
<td class="text-end">@payment.AmountFormatted</td>
</tr>
<tr>
<tr class="payment-data">
<td colspan="2" class="text-end">@payment.PaidFormatted</td>
</tr>
<tr>
<tr class="payment-data">
<td class="text-nowrap text-secondary">Rate</td>
<td class="text-end">@payment.RateFormatted</td>
</tr>
@if (!string.IsNullOrEmpty(payment.Destination))
{
<tr>
<tr class="payment-data">
<td class="text-nowrap text-secondary">Destination</td>
<td class="text-break">
@if (payment.Destination.Length > 69)
{
<span>
<span>@payment.Destination[..30]</span>
<span>@payment.Destination[..19]</span>
<span>...</span>
<span>@payment.Destination.Substring(payment.Destination.Length - 30, 30)</span>
<span>@payment.Destination.Substring(payment.Destination.Length - 20, 20)</span>
</span>
}
else
@ -192,7 +245,7 @@
}
@if (!string.IsNullOrEmpty(payment.PaymentProof))
{
<tr>
<tr class="payment-data">
<td class="text-nowrap text-secondary">Pay Proof</td>
<td class="text-break">@payment.PaymentProof</td>
</tr>
@ -216,7 +269,9 @@
<hr class="w-100 my-0 bg-none"/>
</center>
</body>
<script src="~/main/utils.js" asp-append-version="true"></script>
<script>
formatDateTimes();
window.print();
</script>
</html>

View File

@ -3,7 +3,6 @@
@model BTCPayServer.Services.Altcoins.Monero.UI.UIMoneroLikeStoreController.MoneroLikePaymentMethodListViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage("Monero Settings", "Monero Settings", "Monero Settings");
ViewData["NavPartialName"] = "../UIStores/_Nav";
}

View File

@ -144,7 +144,7 @@
<div class="accordion-body">
<div class="form-group">
<label asp-for="CustomCSSLink" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/#2-bootstrap-themes" target="_blank" rel="noreferrer noopener" title="More information...">
<a href="https://docs.btcpayserver.org/Development/Theme/" target="_blank" rel="noreferrer noopener" title="More information...">
<vc:icon symbol="info" />
</a>
<input asp-for="CustomCSSLink" class="form-control" />

View File

@ -118,7 +118,10 @@
</p>
<dl class="mt-n1 mb-4" v-if="srvModel.amountCollected > 0 && srvModel.amountDue > 0">
<div class="progress bg-light d-flex mb-3 d-print-none" style="height:5px">
<div class="progress-bar bg-primary" role="progressbar" style="width:@(Model.AmountCollected/Model.Amount*100)%" v-bind:style="{ width: (srvModel.amountCollected/srvModel.amount*100) + '%' }"></div>
@{
var prcnt = Model.Amount == 0? 100: Model.AmountCollected / Model.Amount * 100;
}
<div class="progress-bar bg-primary" role="progressbar" style="width:@prcnt%" v-bind:style="{ width: (srvModel.amountCollected/srvModel.amount*100) + '%' }"></div>
</div>
<div class="d-flex flex-wrap gap-3 align-items-center justify-content-between">
<div class="d-flex d-print-inline-block flex-column gap-1">

View File

@ -74,7 +74,7 @@
<div class="accordion-body">
<div class="form-group">
<label asp-for="CustomCSSLink" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/#2-bootstrap-themes" target="_blank" rel="noreferrer noopener">
<a href="https://docs.btcpayserver.org/Development/Theme/" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
<input asp-for="CustomCSSLink" class="form-control" />

View File

@ -58,7 +58,7 @@
<select class="form-select w-auto" asp-for="SelectedPaymentMethod" asp-items="Model.PaymentMethods.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString()))"></select>
}
<button type="button" class="btn btn-secondary only-for-js" data-bs-toggle="modal" data-bs-target="#scanModal" title="Scan destination with camera" id="scandestination-button">
<i class="fa fa-camera"></i>
<vc:icon symbol="scan-qr"/>
</button>
</div>
</div>
@ -202,7 +202,7 @@
</p>
@if (Model.LnurlEndpoint is not null)
{
<p id="BoltcardActions" style="visibility:hidden">
<p id="BoltcardActions">
<a id="SetupBoltcard" asp-action="SetupBoltcard" asp-controller="UIPullPayment" asp-route-pullPaymentId="@Model.Id" asp-route-command="configure-boltcard">
Setup Boltcard
</a>
@ -226,14 +226,13 @@
<script src="~/vendor/ur-registry/urlib.min.js" asp-append-version="true"></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
const isAndroid = /(android)/i.test(navigator.userAgent);
if (isAndroid) {
var isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (isMobile) {
document.getElementById("SetupBoltcard").setAttribute('target', '_blank');
document.getElementById("SetupBoltcard").setAttribute('href', @Safe.Json(@Model.SetupDeepLink));
document.getElementById("SetupBoltcard").setAttribute('href', @Safe.Json(Model.SetupDeepLink));
document.getElementById("ResetBoltcard").setAttribute('target', '_blank');
document.getElementById("ResetBoltcard").setAttribute('href', @Safe.Json(@Model.ResetDeepLink));
document.getElementById("ResetBoltcard").setAttribute('href', @Safe.Json(Model.ResetDeepLink));
}
document.getElementById("BoltcardActions").style.visibility = "visible";
window.qrApp = initQRShow({});
delegate('click', 'button[page-qr]', event => {

View File

@ -20,6 +20,7 @@
#app .table-responsive { max-height: 80vh; }
#app #charts { gap: var(--btcpay-space-l) var(--btcpay-space-xxl); }
#app #charts article { flex: 1 1 450px; }
main .dropdown-menu.show { z-index: 99999; }
</style>
}
@ -32,7 +33,7 @@
</a>
</h2>
<div class="d-flex flex-wrap gap-3">
<a cheat-mode="true" class="btn btn-outline-info text-nowrap" asp-action="StoreReports" asp-route-fakeData="true" asp-route-viewName="@Model.Request?.ViewName">Create fake date</a>
<a cheat-mode="true" class="btn btn-outline-info text-nowrap" asp-action="StoreReports" asp-route-fakeData="true" asp-route-viewName="@Model.Request?.ViewName">Create fake data</a>
<button id="exportCSV" class="btn btn-primary text-nowrap" type="button">Export</button>
</div>
</div>
@ -133,7 +134,7 @@
<template v-else-if="srv.result.fields[columnIndex].type === 'tx_id'">
<vc:truncate-center text="value" is-vue="true" padding="15" classes="truncate-center-id" link="getExplorerUrl(value, row[columnIndex-1])" />
</template>
<template v-else-if="value && ['Address', 'PaymentId'].includes(srv.result.fields[columnIndex].name)" >
<template v-else-if="value && ['Address'].includes(srv.result.fields[columnIndex].name)" >
<vc:truncate-center text="value" is-vue="true" padding="15" classes="truncate-center-id" />
</template>
<template v-else-if="srv.result.fields[columnIndex].type === 'datetime'">{{ displayDate(value) }}</template>

View File

@ -69,7 +69,7 @@
<div>
<label asp-for="CustomTheme" class="form-check-label"></label>
<div class="text-muted">
<a href="https://docs.btcpayserver.org/Development/Theme/#1-custom-themes" target="_blank" rel="noreferrer noopener">Adjust the design</a>
<a href="https://docs.btcpayserver.org/Development/Theme/" target="_blank" rel="noreferrer noopener">Adjust the design</a>
of your BTCPay Server instance to your needs.
</div>
</div>

View File

@ -84,7 +84,7 @@
@plugin
@if (version != null)
{
<span>({version})</span>
<span>(@version)</span>
}
</span>
<form asp-action="UnInstallPlugin" asp-route-plugin="@plugin">

View File

@ -231,7 +231,7 @@
{
var pmi = linkProviders[lpi].Key;
var defaultLink = linkProviders[lpi].Value.BlockExplorerLinkDefault;
var existingOverride = Model.BlockExplorerLinks?.SingleOrDefault(tuple => PaymentMethodId.Parse(tuple.CryptoCode) == pmi);
var existingOverride = Model.BlockExplorerLinks?.FirstOrDefault(tuple => PaymentMethodId.Parse(tuple.CryptoCode) == pmi);
if (existingOverride is null)
{
existingOverride = new PoliciesSettings.BlockExplorerOverrideItem { CryptoCode = pmi.ToStringNormalized(), Link = null };

View File

@ -110,7 +110,7 @@
<div class="accordion-body">
<div class="form-group">
<label asp-for="CustomCSSLink" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/#2-bootstrap-themes" target="_blank" rel="noreferrer noopener" title="More information...">
<a href="https://docs.btcpayserver.org/Development/Theme/" target="_blank" rel="noreferrer noopener" title="More information...">
<vc:icon symbol="info" />
</a>
<input asp-for="CustomCSSLink" class="form-control" />

View File

@ -7,8 +7,6 @@
@model BTCPayServer.Models.WalletViewModels.PayoutsModel
@inject IEnumerable<IPayoutHandler> PayoutHandlers;
@inject PayoutProcessorService _payoutProcessorService;
@inject IEnumerable<IPayoutProcessorFactory> _payoutProcessorFactories;
@{
var storeId = Context.GetRouteValue("storeId") as string;
ViewData.SetActivePage(StoreNavPages.Payouts, $"Payouts{(string.IsNullOrEmpty(Model.PullPaymentName) ? string.Empty : " for pull payment " + Model.PullPaymentName)}", Context.GetStoreData().Id);
@ -24,15 +22,16 @@
return;
stateActions.AddRange(payoutHandler.GetPayoutSpecificActions().Where(pair => pair.Key == Model.PayoutState).SelectMany(pair => pair.Value));
}
switch (Model.PayoutState)
{
case PayoutState.AwaitingApproval:
if (!Model.HasPayoutProcessor) stateActions.Add(("approve-pay", "Approve & Send"));
stateActions.Add(("approve", "Approve"));
stateActions.Add(("approve-pay", "Approve & Send"));
stateActions.Add(("cancel", "Cancel"));
break;
case PayoutState.AwaitingPayment:
stateActions.Add(("pay", "Send"));
if (!Model.HasPayoutProcessor) stateActions.Add(("pay", "Send"));
stateActions.Add(("cancel", "Cancel"));
stateActions.Add(("mark-paid", "Mark as already paid"));
break;
@ -87,11 +86,7 @@
<partial name="_StatusMessage" />
@if (_payoutProcessorFactories.Any(factory => factory.GetSupportedPaymentMethods().Contains(paymentMethodId)) && !(await _payoutProcessorService.GetProcessors(new PayoutProcessorService.PayoutProcessorQuery()
{
Stores = new[] { storeId },
PaymentMethods = new[] { Model.PaymentMethodId }
})).Any())
@if (!Model.HasPayoutProcessor)
{
<div class="alert alert-info mb-5" role="alert" permission="@Policies.CanModifyStoreSettings">
<strong>Pro tip:</strong> There are supported but unconfigured Payout Processors for this payout payment method.<br/>

View File

@ -205,7 +205,7 @@
</div>
<div class="form-group">
<label asp-for="CustomLogo" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/#checkout-page-themes" target="_blank" rel="noreferrer noopener">
<a href="https://docs.btcpayserver.org/Development/Theme/" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
<input asp-for="CustomLogo" class="form-control" />
@ -213,7 +213,7 @@
</div>
<div class="form-group mb-0">
<label asp-for="CustomCSS" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/#checkout-page-themes" target="_blank" rel="noreferrer noopener">
<a href="https://docs.btcpayserver.org/Development/Theme/" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
<input asp-for="CustomCSS" class="form-control" />

View File

@ -9,7 +9,8 @@
@using BTCPayServer.Client
@model StoreDashboardViewModel
@{
ViewData.SetActivePage(StoreNavPages.Dashboard, Model.StoreName, Model.StoreId);
BTCPayServer.Plugins.PluginExceptionHandler.SetDisablePluginIfCrash(Context);
ViewData.SetActivePage(StoreNavPages.Dashboard, Model.StoreName, Model.StoreId);
var store = ViewContext.HttpContext.GetStoreData();
}

View File

@ -41,6 +41,7 @@
}
else
{
<input type="hidden" name="UseCustomSMTP" value="true" />
<partial name="EmailsBody" model="Model" />
}

View File

@ -1 +1,12 @@
<svg width="150" height="100" style="clip-rule:evenodd;fill-rule:evenodd;image-rendering:optimizeQuality;shape-rendering:geometricPrecision;text-rendering:geometricPrecision" version="1.1" id="svg587" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><style id="style324">.st2{fill:#ffc214}.st3{fill:#f9f185}.st0{fill:#222221}.st1{fill:#272425}</style><g id="g931" transform="matrix(.375 0 0 .375 -306.863 -123.51)"><path fill-rule="evenodd" clip-rule="evenodd" d="M911.118 436.635c-7.865-3.02-11.793-11.842-8.774-19.707s11.842-11.793 19.707-8.774l56.773 21.793c.062.026.126.05.19.074l28.48 10.932c7.864 3.02 16.689-.909 19.706-8.774 3.02-7.865-.908-16.688-8.774-19.707v-.003l-28.48-10.932c-7.865-3.02-11.793-11.842-8.774-19.707 3.02-7.865 11.842-11.793 19.707-8.774l83.768 32.155c.2.077.399.158.594.242a84 84 0 0 1 1.08.406c39.325 15.095 58.967 59.21 43.87 98.535-15.095 39.324-59.21 58.965-98.534 43.87a78.402 78.402 0 0 1-2.249-.903c-.36-.11-.72-.232-1.076-.37l-82.117-31.521c-7.865-3.02-11.793-11.842-8.774-19.707s11.842-11.793 19.707-8.774l28.477 10.931-.002-.007.006.002c7.865 3.02 16.688-.909 19.706-8.774 3.02-7.865-.908-16.688-8.773-19.707l-12.817-4.92v-.001z" fill="currentColor" id="path1" style="clip-rule:evenodd;fill:#000;fill-rule:evenodd;stroke-width:1.52532;image-rendering:optimizeQuality;shape-rendering:geometricPrecision;text-rendering:geometricPrecision"/></g></svg>
<svg width="150" height="100" style="clip-rule:evenodd;fill-rule:evenodd;image-rendering:optimizeQuality;shape-rendering:geometricPrecision;text-rendering:geometricPrecision" version="1.1" id="svg587" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#ffffff"/>
<style id="style324">
.st2{fill:#ffc214}
.st3{fill:#f9f185}
.st0{fill:#222221}
.st1{fill:#272425}
</style>
<g id="g931" transform="matrix(.375 0 0 .375 -306.863 -123.51)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M911.118 436.635c-7.865-3.02-11.793-11.842-8.774-19.707s11.842-11.793 19.707-8.774l56.773 21.793c.062.026.126.05.19.074l28.48 10.932c7.864 3.02 16.689-.909 19.706-8.774 3.02-7.865-.908-16.688-8.774-19.707v-.003l-28.48-10.932c-7.865-3.02-11.793-11.842-8.774-19.707 3.02-7.865 11.842-11.793 19.707-8.774l83.768 32.155c.2.077.399.158.594.242a84 84 0 0 1 1.08.406c39.325 15.095 58.967 59.21 43.87 98.535-15.095 39.324-59.21 58.965-98.534 43.87a78.402 78.402 0 0 1-2.249-.903c-.36-.11-.72-.232-1.076-.37l-82.117-31.521c-7.865-3.02-11.793-11.842-8.774-19.707s11.842-11.793 19.707-8.774l28.477 10.931-.002-.007.006.002c7.865 3.02 16.688-.909 19.706-8.774 3.02-7.865-.908-16.688-8.773-19.707l-12.817-4.92v-.001z" fill="currentColor" id="path1" style="clip-rule:evenodd;fill:#000;fill-rule:evenodd;stroke-width:1.52532;image-rendering:optimizeQuality;shape-rendering:geometricPrecision;text-rendering:geometricPrecision"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,3 @@
<svg width="150" height="100" viewBox="0 0 150 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.4825 0.862305H88.0496C89.5663 0.862305 90.9675 1.64827 91.7239 2.92338L110.244 34.1419C111.204 35.7609 110.919 37.8043 109.549 39.1171L58.5729 87.9703C56.9216 89.5528 54.2652 89.5528 52.6139 87.9703L1.70699 39.1831C0.305262 37.8398 0.0427812 35.7367 1.07354 34.1077L20.8696 2.82322C21.6406 1.60483 23.0087 0.862305 24.4825 0.862305ZM79.8419 14.8003V23.5597H61.7343V29.6329C74.4518 30.2819 83.9934 32.9475 84.0642 36.1425L84.0638 42.803C83.993 45.998 74.4518 48.6635 61.7343 49.3125V64.2168H49.7105V49.3125C36.9929 48.6635 27.4513 45.998 27.3805 42.803L27.381 36.1425C27.4517 32.9475 36.9929 30.2819 49.7105 29.6329V23.5597H31.6028V14.8003H79.8419ZM55.7224 44.7367C69.2943 44.7367 80.6382 42.4827 83.4143 39.4727C81.0601 36.9202 72.5448 34.9114 61.7343 34.3597V40.7183C59.7966 40.8172 57.7852 40.8693 55.7224 40.8693C53.6595 40.8693 51.6481 40.8172 49.7105 40.7183V34.3597C38.8999 34.9114 30.3846 36.9202 28.0304 39.4727C30.8066 42.4827 42.1504 44.7367 55.7224 44.7367Z" fill="#009393" transform="translate(20, 5)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -187,6 +187,7 @@
}
function createTable(summaryDefinition, fields, rows) {
rows = clone(rows);
var groupIndices = summaryDefinition.groups.map(g => fields.findIndex((a) => a === g)).filter(g => g !== -1);
var aggregatesIndices = summaryDefinition.aggregates.map(g => fields.findIndex((a) => a === g)).filter(g => g !== -1);
aggregatesIndices = aggregatesIndices.filter(g => g !== -1);

View File

@ -185,7 +185,7 @@ function downloadCSV() {
// Convert ISO8601 dates to YYYY-MM-DD HH:mm:ss so the CSV easily integrate with Excel
modifyFields(srv.result.fields, data, 'amount', displayValue)
modifyFields(srv.result.fields, data, 'datetime', v => v? moment(v).format('YYYY-MM-DD hh:mm:ss'): v);
modifyFields(srv.result.fields, data, 'datetime', v => v ? moment(v).format('YYYY-MM-DD HH:mm:ss') : v);
const csv = Papa.unparse({ fields: srv.result.fields.map(f => f.name), data });
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
saveAs(blob, "export.csv");

View File

@ -473,7 +473,7 @@ svg.icon-note {
font-weight: var(--btcpay-font-weight-bold);
}
.widget .table {
.widget *:not([class*='table-responsive']) > .table {
margin-left: -.5rem;
margin-right: -.5rem;
width: calc(100% + 1rem);

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