Compare commits

...

45 Commits

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

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

Fixes .

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

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

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

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

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

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

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

* Adding Tether to _BTCPaySupporters partial as well

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

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

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

* Update info text

* Make "Employee" default role on store settings

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

Closes .

* Obfuscate contact email on public pages

Closes .

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

* Fix additional bug when ln conn strings without server would crash
2024-03-22 10:06:38 +01:00
86 changed files with 1027 additions and 469 deletions
BTCPayServer.Abstractions
BTCPayServer.Client
BTCPayServer.Common
BTCPayServer.Data
BTCPayServer.Rating
BTCPayServer.Tests
BTCPayServer
BTCPayServer.csproj
Components
MainNav
StoreRecentInvoices
StoreRecentTransactions
Controllers
Data/Payouts/LightningLike
Filters
HostedServices
Hosting
Models
Payments/Lightning
Plugins/Altcoins/Monero/RPC/Models
SearchString.cs
Services
Views
wwwroot
Build
Changelog.mdDockerfileREADME.md
docs

@ -32,8 +32,8 @@
</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="Microsoft.EntityFrameworkCore" Version="8.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.5" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.0-beta.2" />
</ItemGroup>

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

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

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

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

@ -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.5">
<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.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj" />

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

@ -1347,17 +1347,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";

@ -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);
@ -2676,21 +2676,19 @@ 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);
// Guest user can access recent transactions
s.GoToHome();
@ -2837,9 +2835,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);

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

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

@ -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.02.2
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,14 @@ services:
- bitcoind
merchant_lightningd:
image: btcpayserver/lightning:v23.08-dev
image: btcpayserver/lightning:v24.02.2
stop_signal: SIGKILL
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
@ -224,7 +226,7 @@ services:
- "5432"
merchant_lnd:
image: btcpayserver/lnd:v0.17.4-beta
image: btcpayserver/lnd:v0.18.0-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@ -259,7 +261,7 @@ services:
- bitcoind
customer_lnd:
image: btcpayserver/lnd:v0.17.4-beta
image: btcpayserver/lnd:v0.18.0-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"

@ -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.02.2
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,14 @@ services:
- bitcoind
merchant_lightningd:
image: btcpayserver/lightning:v23.08-dev
image: btcpayserver/lightning:v24.02.2
stop_signal: SIGKILL
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 +213,7 @@ services:
- "5432"
merchant_lnd:
image: btcpayserver/lnd:v0.17.4-beta
image: btcpayserver/lnd:v0.18.0-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"
@ -248,7 +250,7 @@ services:
- bitcoind
customer_lnd:
image: btcpayserver/lnd:v0.17.4-beta
image: btcpayserver/lnd:v0.18.0-beta
restart: unless-stopped
environment:
LND_CHAIN: "btc"

@ -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.5" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.5" />
</ItemGroup>
<ItemGroup>

@ -296,6 +296,15 @@
</li>
</ul>
</li>
@if (!string.IsNullOrWhiteSpace(Model.ContactUrl))
{
<li class="nav-item">
<a href="@Model.ContactUrl" class="nav-link" id="Nav-ContactUs">
<vc:icon symbol="contact"/>
<span>Contact Us</span>
</a>
</li>
}
</ul>
}
</nav>

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

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

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

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

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

@ -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");
}
@ -226,15 +226,40 @@ 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
if (receiptData.Any())
{
vm.AdditionalData = receiptData;
}
}
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);
}
@ -1073,7 +1098,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);

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

@ -277,6 +277,10 @@ namespace BTCPayServer.Controllers
if (FormDataService.Validate(form, ModelState))
{
prBlob.FormResponse = FormDataService.GetValues(form);
if(string.IsNullOrEmpty(prBlob.Email) && form.GetFieldByFullName("buyerEmail") is { } emailField)
{
prBlob.Email = emailField.Value;
}
result.SetBlob(prBlob);
await _PaymentRequestRepository.CreateOrUpdatePaymentRequest(result);
return RedirectToAction("PayPaymentRequest", new { payReqId });

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

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

@ -1,4 +1,3 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
@ -16,13 +15,12 @@ namespace BTCPayServer.Controllers
{
[Route("server/roles")]
public async Task<IActionResult> ListRoles(
[FromServices] StoreRepository storeRepository,
RolesViewModel model,
string sortOrder = null
)
{
var roles = await storeRepository.GetStoreRoles(null, true);
var defaultRole = (await storeRepository.GetDefaultRole()).Role;
var roles = await _StoreRepository.GetStoreRoles(null, true);
var defaultRole = (await _StoreRepository.GetDefaultRole()).Role;
model ??= new RolesViewModel();
model.DefaultRole = defaultRole;
@ -44,32 +42,26 @@ namespace BTCPayServer.Controllers
}
[HttpGet("server/roles/{role}")]
public async Task<IActionResult> CreateOrEditRole(
[FromServices] StoreRepository storeRepository,
string role)
public async Task<IActionResult> CreateOrEditRole(string role)
{
if (role == "create")
{
ModelState.Remove(nameof(role));
return View(new UpdateRoleViewModel());
}
else
var roleData = await _StoreRepository.GetStoreRole(new StoreRoleId(role));
if (roleData == null)
return NotFound();
return View(new UpdateRoleViewModel
{
var roleData = await storeRepository.GetStoreRole(new StoreRoleId(role));
if (roleData == null)
return NotFound();
return View(new UpdateRoleViewModel()
{
Policies = roleData.Permissions,
Role = roleData.Role
});
}
Policies = roleData.Permissions,
Role = roleData.Role
});
}
[HttpPost("server/roles/{role}")]
public async Task<IActionResult> CreateOrEditRole(
[FromServices] StoreRepository storeRepository,
[FromRoute] string role, UpdateRoleViewModel viewModel)
public async Task<IActionResult> CreateOrEditRole([FromRoute] string role, UpdateRoleViewModel viewModel)
{
string successMessage = null;
if (role == "create")
@ -80,7 +72,7 @@ namespace BTCPayServer.Controllers
else
{
successMessage = "Role updated";
var storeRole = await storeRepository.GetStoreRole(new StoreRoleId(role));
var storeRole = await _StoreRepository.GetStoreRole(new StoreRoleId(role));
if (storeRole == null)
return NotFound();
}
@ -90,7 +82,7 @@ namespace BTCPayServer.Controllers
return View(viewModel);
}
var r = await storeRepository.AddOrUpdateStoreRole(new StoreRoleId(role), viewModel.Policies);
var r = await _StoreRepository.AddOrUpdateStoreRole(new StoreRoleId(role), viewModel.Policies);
if (r is null)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
@ -113,11 +105,9 @@ namespace BTCPayServer.Controllers
[HttpGet("server/roles/{role}/delete")]
public async Task<IActionResult> DeleteRole(
[FromServices] StoreRepository storeRepository,
string role)
public async Task<IActionResult> DeleteRole(string role)
{
var roleData = await storeRepository.GetStoreRole(new StoreRoleId(role), true);
var roleData = await _StoreRepository.GetStoreRole(new StoreRoleId(role), true);
if (roleData == null)
return NotFound();
@ -131,12 +121,10 @@ namespace BTCPayServer.Controllers
}
[HttpPost("server/roles/{role}/delete")]
public async Task<IActionResult> DeleteRolePost(
[FromServices] StoreRepository storeRepository,
string role)
public async Task<IActionResult> DeleteRolePost(string role)
{
var roleId = new StoreRoleId(role);
var roleData = await storeRepository.GetStoreRole(roleId, true);
var roleData = await _StoreRepository.GetStoreRole(roleId, true);
if (roleData == null)
return NotFound();
if (roleData.IsUsed is true)
@ -144,7 +132,7 @@ namespace BTCPayServer.Controllers
return BadRequest();
}
var errorMessage = await storeRepository.RemoveStoreRole(roleId);
var errorMessage = await _StoreRepository.RemoveStoreRole(roleId);
if (errorMessage is null)
{
@ -159,19 +147,16 @@ namespace BTCPayServer.Controllers
}
[HttpGet("server/roles/{role}/default")]
public async Task<IActionResult> SetDefaultRole(
[FromServices] StoreRepository storeRepository,
string role)
public async Task<IActionResult> SetDefaultRole(string role)
{
var resolved = await storeRepository.ResolveStoreRoleId(null, role);
var resolved = await _StoreRepository.ResolveStoreRoleId(null, role);
if (resolved is null)
{
TempData[WellKnownTempData.ErrorMessage] = "Role could not be set as default";
}
else
{
await storeRepository.SetDefaultRole(role);
await _StoreRepository.SetDefaultRole(role);
TempData[WellKnownTempData.SuccessMessage] = "Role set default";
}

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

@ -45,7 +45,7 @@ namespace BTCPayServer.Controllers
[HttpGet("{storeId}/users")]
public async Task<IActionResult> StoreUsers()
{
var vm = new StoreUsersViewModel { Role = StoreRoleId.Guest.Role };
var vm = new StoreUsersViewModel { Role = StoreRoleId.Employee.Role };
await FillUsers(vm);
return View(vm);
}

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

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

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

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

@ -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,
@ -327,6 +345,17 @@ namespace BTCPayServer.HostedServices
return new[] { Loop() };
}
private void TopUpInvoice(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)

@ -347,6 +347,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 +387,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>());

@ -17,6 +17,7 @@ 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; }

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

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

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

@ -132,7 +132,7 @@ namespace BTCPayServer.Payments.Lightning
// LNDhub-compatible implementations might not offer all of GetInfo data.
// Skip checks in those cases, see https://github.com/lnbits/lnbits/issues/1182
var isLndHub = client is LndHubLightningClient;
LightningNodeInformation info;
try
{
@ -163,11 +163,14 @@ namespace BTCPayServer.Payments.Lightning
? info.NodeInfoList.Where(i => i.IsTor == preferOnion.Value).ToArray()
: info.NodeInfoList.Select(i => i).ToArray();
var blocksGap = summary.Status.ChainHeight - info.BlockHeight;
if (blocksGap > 10 && !(isLndHub && info.BlockHeight == 0))
if (summary.Status is not null)
{
throw new PaymentMethodUnavailableException(
$"The lightning node is not synched ({blocksGap} blocks left)");
var blocksGap = summary.Status.ChainHeight - info.BlockHeight;
if (blocksGap > 10 && !(isLndHub && info.BlockHeight == 0))
{
throw new PaymentMethodUnavailableException(
$"The lightning node is not synched ({blocksGap} blocks left)");
}
}
return nodeInfo;
}

@ -508,8 +508,15 @@ namespace BTCPayServer.Payments.Lightning
try
{
var lightningClient = _lightningClientFactory.Create(ConnectionString, _network);
if(lightningClient is null)
return;
uri = lightningClient.GetServerUri();
logUrl = string.IsNullOrEmpty(uri.UserInfo) ? uri.ToString() : uri.ToString().Replace(uri.UserInfo, "***");
logUrl = uri switch
{
null when LightningConnectionStringHelper.ExtractValues(ConnectionString, out var type) is not null => type,
null => string.Empty,
_ => string.IsNullOrEmpty(uri.UserInfo) ? uri.ToString() : uri.ToString().Replace(uri.UserInfo, "***")
};
Logs.PayServer.LogInformation("{CryptoCode} (Lightning): Start listening {Uri}", _network.CryptoCode, logUrl);
using var session = await lightningClient.Listen(cancellation);
// Just in case the payment arrived after our last poll but before we listened.

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

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

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

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

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

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

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

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

@ -163,13 +163,13 @@
<span class="badge text-bg-warning inventory" v-if="item.inventory">
{{ item.inventory > 0 ? `${item.inventory} left` : "Sold out" }}
</span>
<div class="d-flex align-items-center gap-2">
<button type="button" v-on:click="updateQuantity(item.id, -1)" class="btn btn-light p-1 rounded-pill d-flex align-items-center justify-content-center">
<vc:icon symbol="minus" />
<div class="d-flex align-items-center gap-2 quantities">
<button type="button" v-on:click="updateQuantity(item.id, -1)" class="btn btn-minus">
<span><vc:icon symbol="minus" /></span>
</button>
<input class="form-control hide-number-spin w-50px" type="number" placeholder="Qty" min="1" step="1" :max="item.inventory" v-model.number="item.count">
<button type="button" v-on:click="updateQuantity(item.id, +1)" class="btn btn-light p-1 rounded-pill d-flex align-items-center justify-content-center">
<vc:icon symbol="plus" />
<input class="form-control hide-number-spin w-50px text-center" type="number" placeholder="Qty" min="1" step="1" :max="item.inventory" v-model.number="item.count">
<button type="button" v-on:click="updateQuantity(item.id, +1)" class="btn btn-plus">
<span><vc:icon symbol="plus" /></span>
</button>
</div>
</div>

@ -115,8 +115,8 @@
<div class="d-flex align-items-start w-100 gap-3">
@if (!string.IsNullOrWhiteSpace(item.Image))
{
<div class="img">
<img src="@item.Image" alt="@item.Title" />
<div class="img d-none d-sm-block">
<img src="@item.Image" alt="@item.Title" asp-append-version="true" />
</div>
}
<div class="d-flex flex-column gap-2">
@ -138,13 +138,13 @@
}
</div>
</div>
<div class="d-flex align-items-center gap-2 ms-auto" v-if="inStock(@index)">
<button type="button" v-on:click="updateQuantity(`@Safe.Raw(item.Id)`, -1, true)" class="btn btn-light p-1 rounded-pill d-flex align-items-center justify-content-center btn-minus" :disabled="getQuantity(`@Safe.Raw(item.Id)`) <= 0">
<vc:icon symbol="minus" />
<div class="d-flex align-items-center gap-2 ms-auto quantities">
<button type="button" v-on:click="updateQuantity(`@Safe.Raw(item.Id)`, -1, true)" class="btn btn-minus" :disabled="getQuantity(`@Safe.Raw(item.Id)`) <= 0">
<span><vc:icon symbol="minus" /></span>
</button>
<div class="quantity text-center fs-6" style="width:2rem">{{ getQuantity(`@Safe.Raw(item.Id)`) }}</div>
<button type="button" v-on:click="updateQuantity(`@Safe.Raw(item.Id)`, +1, true)" class="btn btn-light p-1 rounded-pill d-flex align-items-center justify-content-center btn-plus">
<vc:icon symbol="plus" />
<div class="quantity text-center fs-5" style="width:2rem">{{ getQuantity(`@Safe.Raw(item.Id)`) }}</div>
<button type="button" v-on:click="updateQuantity(`@Safe.Raw(item.Id)`, +1, true)" class="btn btn-plus" :disabled="!inStock(@index)">
<span><vc:icon symbol="plus" /></span>
</button>
</div>
</div>

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

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

@ -153,17 +153,11 @@
<div class="col-xl-5 offcanvas-xl offcanvas-end" tabindex="-1" ref="editorOffcanvas">
<div class="offcanvas-header p-3">
<h5 class="offcanvas-title">Edit Item</h5>
<button type="button" class="btn-close" aria-label="Close" v-on:click="hideOffcanvas">
<vc:icon symbol="close" />
</button>
<button type="button" class="btn btn-sm rounded-pill" :class="{ 'btn-primary': itemChanged, 'btn-outline-secondary': !itemChanged }" v-on:click="hideOffcanvas" v-text="itemChanged ? 'Apply' : 'Close'"></button>
</div>
<div class="offcanvas-body p-3 p-xl-0">
<item-editor ref="itemEditor" :item="selectedItem" class="bg-tile w-100 p-xl-4 rounded" />
</div>
<div class="offcanvas-header p-3">
<button class="btn btn-primary" type="button" v-on:click="() => { $refs.itemEditor.apply(); hideOffcanvas() }">Apply and close</button>
<button class="btn btn-secondary" type="button" v-on:click="hideOffcanvas">Cancel</button>
</div>
</div>
</div>
</div>

File diff suppressed because one or more lines are too long

@ -1,13 +1,19 @@
@using BTCPayServer.Services
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Components.MainLogo
@using System.Text
@inject SettingsRepository SettingsRepository
@{
Layout = "_LayoutSimple";
ViewBag.ShowTitle ??= true;
ViewBag.ShowLeadText ??= false;
// obfuscate email on public page, decode via JS (see below)
var settings = await SettingsRepository.GetSettingAsync<ServerSettings>() ?? new ServerSettings();
var contactUrl = settings.ContactUrl;
if (contactUrl?.StartsWith("mailto:") is true)
{
contactUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(contactUrl));
}
}
@section PageHeadContent {
@ -64,11 +70,20 @@
@RenderBody()
</div>
@if (!string.IsNullOrWhiteSpace(settings.ContactUrl))
@if (!string.IsNullOrWhiteSpace(contactUrl))
{
<p class="text-center mt-n5 mb-5 pt-2">
<a class="text-secondary" href="@settings.ContactUrl" id="ContactLink">Contact Us</a>
<a class="text-secondary" href="@contactUrl" id="ContactLink">Contact Us</a>
</p>
@if (contactUrl != settings.ContactUrl)
{
<script>
(function() {
const link = document.getElementById('ContactLink')
link.setAttribute('href', atob(link.getAttribute('href')))
})()
</script>
}
}
<div class="row justify-content-center mt-5">

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

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

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

@ -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,19 +113,29 @@
@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>
}
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
}
@if (hasCart && !IsManualEntryCart(Model.AdditionalData))
{
_ = 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>
<td class="text-secondary">@key</td>
@ -119,33 +143,62 @@
</tr>
}
}
@if (Model.AdditionalData.ContainsKey("Subtotal"))
else if (cart is ICollection<object> { Count: > 0 } cartCollection)
{
@foreach (var value in cartCollection)
{
<tr>
<td class="text-end">@value</td>
</tr>
}
}
if (hasSubtotal && (hasDiscount || hasTip))
{
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
<tr>
<td class="text-secondary">Subtotal</td>
<td class="text-end">@Model.AdditionalData["Subtotal"]</td>
<td class="text-end">@subtotal</td>
</tr>
}
@if (Model.AdditionalData.ContainsKey("Discount"))
if (hasDiscount)
{
<tr>
<td class="text-secondary">Discount</td>
<td class="text-end">@Model.AdditionalData["Discount"]</td>
<td class="text-end">@discount</td>
</tr>
}
@if (Model.AdditionalData.ContainsKey("Tip"))
if (hasTip)
{
<tr>
<td class="text-secondary">Tip</td>
<td class="text-end">@Model.AdditionalData["Tip"]</td>
<td class="text-end">@tip</td>
</tr>
}
if (hasTotal)
{
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
<tr>
<th class="text-secondary">Total</th>
<td class="text-end fw-semibold">@total</td>
</tr>
}
}
else
{
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
<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>
}
@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];
@ -178,9 +231,9 @@
@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
@ -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>

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

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

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

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

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

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

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

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

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

@ -4,13 +4,13 @@
@using BTCPayServer.Client
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model StoreUsersViewModel
@inject IScopeProvider ScopeProvider
@inject StoreRepository StoreRepository
@{
var storeId = Context.GetStoreData().Id;
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.Users, "Store Users", Context.GetStoreData().Id);
ViewData.SetActivePage(StoreNavPages.Users, "Store Users", storeId);
var roles = new SelectList(
await StoreRepository.GetStoreRoles(ScopeProvider.GetCurrentStoreId()),
await StoreRepository.GetStoreRoles(storeId),
nameof(StoreRepository.StoreRole.Id), nameof(StoreRepository.StoreRole.Role),
Model.Role);
}
@ -26,8 +26,9 @@
<div class="col-xxl-constrain">
<h3 class="mb-3">@ViewData["Title"]</h3>
<p>
Give other registered BTCPay Server users access to your store.<br />
Guests will not be able to see or modify the store settings.
Give other registered BTCPay Server users access to your store. See the
<a asp-controller="UIStores" asp-action="ListRoles" asp-route-storeId="@storeId">roles</a>
for granted permissions.
</p>
@if (!ViewContext.ModelState.IsValid)

@ -26,6 +26,7 @@
<symbol id="home" viewBox="0 0 24 24" fill="none"><path d="M15.6923 19.3845H8.30766C6.27689 19.3845 4.61536 17.7229 4.61536 15.6922V8.30754C4.61536 6.27677 6.27689 4.61523 8.30766 4.61523H15.6923C17.723 4.61523 19.3846 6.27677 19.3846 8.30754V15.6922C19.3846 17.7229 17.723 19.3845 15.6923 19.3845Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M7.56921 11.938H9.04614L10.5846 14.1534L13.3538 9.72266L14.8923 11.938H16.2461" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="hot-wallet" viewBox="0 0 32 32" fill="none"><path d="M26.5362 7.08746H25.9614V3.25512C25.9614 2.10542 25.3865 1.14734 24.6201 0.572488C23.8536 -0.00236247 22.7039 -0.193979 21.7458 -0.00236246L4.11707 5.36291C2.00929 5.93776 0.667969 7.85392 0.667969 9.96171V12.0695V12.836V27.3988C0.667969 30.0815 2.77575 32.1893 5.45839 32.1893H26.5362C29.2189 32.1893 31.3267 30.0815 31.3267 27.3988V12.0695C31.3267 9.38686 29.2189 7.08746 26.5362 7.08746ZM4.69192 7.08746L22.129 1.91381C22.5123 1.72219 23.0871 1.91381 23.4704 2.10542C23.8536 2.29704 24.0452 2.87189 24.0452 3.25512V7.08746H5.45839C4.88354 7.08746 4.5003 7.27908 3.92545 7.47069C4.11707 7.27908 4.5003 7.08746 4.69192 7.08746ZM29.4105 27.2072C29.4105 28.7402 28.0692 30.0815 26.5362 30.0815H5.45839C3.92545 30.0815 2.58414 28.7402 2.58414 27.2072V12.836V11.8779C2.58414 10.3449 3.92545 9.00362 5.45839 9.00362H26.5362C28.0692 9.00362 29.4105 10.3449 29.4105 11.8779V27.2072Z" fill="currentColor"/><path d="M25.9591 21.6487C27.0174 21.6487 27.8753 20.7908 27.8753 19.7326C27.8753 18.6743 27.0174 17.8164 25.9591 17.8164C24.9009 17.8164 24.043 18.6743 24.043 19.7326C24.043 20.7908 24.9009 21.6487 25.9591 21.6487Z" fill="currentColor"/></symbol>
<symbol id="info" viewBox="0 0 24 24" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 22.39c5.739 0 10.39-4.651 10.39-10.39C22.39 6.261 17.74 1.61 12 1.61 6.261 1.61 1.61 6.26 1.61 12c0 5.739 4.651 10.39 10.39 10.39zm0-2.597a7.793 7.793 0 1 0 0-15.586 7.793 7.793 0 0 0 0 15.586z" fill="currentColor"/><path d="M12 6.805a1.299 1.299 0 1 0 0 2.597 1.299 1.299 0 0 0 0-2.597zM10.701 12s0-1.299 1.299-1.299S13.299 12 13.299 12v3.897s0 1.298-1.299 1.298-1.299-1.298-1.299-1.298z" fill="currentColor"/></symbol>
<symbol id="contact" viewBox="0 0 24 24" fill="none"><g transform="scale(.65) translate(7, 7)" fill="currentColor"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 22.39c5.739 0 10.39-4.651 10.39-10.39C22.39 6.261 17.74 1.61 12 1.61 6.261 1.61 1.61 6.26 1.61 12c0 5.739 4.651 10.39 10.39 10.39zm0-2.597a7.793 7.793 0 1 0 0-15.586 7.793 7.793 0 0 0 0 15.586z"/><path d="M12 6.805a1.299 1.299 0 1 0 0 2.597 1.299 1.299 0 0 0 0-2.597zM10.701 12s0-1.299 1.299-1.299S13.299 12 13.299 12v3.897s0 1.298-1.299 1.298-1.299-1.298-1.299-1.298z"/></g></symbol>
<symbol id="invoice-2" viewBox="0 0 16 18" fill="none"><path d="M1.16699 3V16.5429L3.50033 15.6286L5.83366 17L8.16699 15.6286L10.5003 17L12.8337 15.6286L15.167 16.5429V3C15.167 1.89543 14.2716 1 13.167 1H3.16699C2.06242 1 1.16699 1.89543 1.16699 3Z" stroke="currentColor" stroke-width="1.6"/><path d="M4.66699 5H11.667" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M4.66699 8.5H11.667" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M4.66699 12H8.66699" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
<symbol id="invoice-expired" viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="22.5" stroke="currentColor" stroke-width="3"/><path d="m17 31 14-14m-14 0 14 14" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="invoice" viewBox="0 0 24 24" fill="none"><path d="M8.30766 4.61523H15.6923C17.723 4.61523 19.3846 6.27677 19.3846 8.30754V15.6922C19.3846 17.7229 17.723 19.3845 15.6923 19.3845H8.30766C6.27689 19.3845 4.61536 17.7229 4.61536 15.6922V8.30754C4.61536 6.27677 6.27689 4.61523 8.30766 4.61523Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M8.30774 8.92383H15.6924" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.30774 12H15.6924" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.30774 15.0156H12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></symbol>

Before

(image error) Size: 78 KiB

After

(image error) Size: 78 KiB

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

(image error) Size: 1.4 KiB

After

(image error) Size: 1.4 KiB

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

(image error) Size: 1.2 KiB

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

@ -235,7 +235,8 @@ document.addEventListener('DOMContentLoaded', () => {
return {
items,
selectedItem: null,
editorOffcanvas: null
selectedItemInitial: null,
editorOffcanvas: null,
}
},
computed: {
@ -247,6 +248,19 @@ document.addEventListener('DOMContentLoaded', () => {
(item.categories || []).forEach(category => { res.add(category); });
return res;
}, new Set()));
},
itemChanged() {
return this.selectedItem && this.selectedItemInitial && (
this.selectedItem.id !== this.selectedItemInitial.id ||
this.selectedItem.title !== this.selectedItemInitial.title ||
this.selectedItem.price !== this.selectedItemInitial.price ||
this.selectedItem.image !== this.selectedItemInitial.image ||
this.selectedItem.disabled !== this.selectedItemInitial.disabled ||
this.selectedItem.inventory !== this.selectedItemInitial.inventory ||
this.selectedItem.priceType !== this.selectedItemInitial.priceType ||
this.selectedItem.categories !== this.selectedItemInitial.categories ||
this.selectedItem.description !== this.selectedItemInitial.description
)
}
},
methods: {
@ -254,7 +268,7 @@ document.addEventListener('DOMContentLoaded', () => {
const items = parseConfig(event.target.value)
if (!items) return
this.items = items
this.selectedItem = null
this.selectedItem = this.selectedItemInitial = null
},
addItem(event) {
const length = this.items.push({
@ -268,16 +282,16 @@ document.addEventListener('DOMContentLoaded', () => {
inventory: null,
disabled: false
})
this.selectedItem = this.items[length - 1]
this.showOffcanvas()
this.selectItem(null, length - 1)
},
selectItem(event, index) {
this.selectedItem = this.items[index]
this.selectedItemInitial = { ...this.selectedItem } // pristine copy
this.showOffcanvas()
},
removeItem(event, index) {
this.items.splice(index, 1)
this.selectedItem = null
this.selectedItem = this.selectedItemInitial = null
},
sortItems(event) {
const { newIndex, oldIndex } = event

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

@ -2,19 +2,6 @@ const baseUrl = Object.values(document.scripts).find(s => s.src.includes('/main/
const flatpickrInstances = [];
const formatDateTimes = format => {
// select only elements which haven't been initialized before, those without data-localized
document.querySelectorAll("time[datetime]:not([data-localized])").forEach($el => {
const date = new Date($el.getAttribute("datetime"));
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
const { dateStyle = 'short', timeStyle = 'short' } = $el.dataset;
// initialize and set localized attribute
$el.dataset.localized = new Intl.DateTimeFormat('default', { dateStyle, timeStyle }).format(date);
// set text to chosen mode
const mode = format || $el.dataset.initial;
if ($el.dataset[mode]) $el.innerText = $el.dataset[mode];
});
};
const switchTimeFormat = event => {
const curr = event.target.dataset.mode || 'localized';
@ -166,8 +153,9 @@ document.addEventListener("DOMContentLoaded", () => {
}
// initialize timezone offset value if field is present in page
var timezoneOffset = new Date().getTimezoneOffset();
$("#TimezoneOffset").val(timezoneOffset);
const $timezoneOffset = document.getElementById("TimezoneOffset");
const timezoneOffset = new Date().getTimezoneOffset();
if ($timezoneOffset) $timezoneOffset.value = timezoneOffset;
// localize all elements that have localizeDate class
formatDateTimes();

@ -15,3 +15,17 @@ function debounce(key, fn, delay = 250) {
clearTimeout(DEBOUNCE_TIMERS[key])
DEBOUNCE_TIMERS[key] = setTimeout(fn, delay)
}
function formatDateTimes(format) {
// select only elements which haven't been initialized before, those without data-localized
document.querySelectorAll("time[datetime]:not([data-localized])").forEach($el => {
const date = new Date($el.getAttribute("datetime"));
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat
const { dateStyle = 'short', timeStyle = 'short' } = $el.dataset;
// initialize and set localized attribute
$el.dataset.localized = new Intl.DateTimeFormat('default', { dateStyle, timeStyle }).format(date);
// set text to chosen mode
const mode = format || $el.dataset.initial;
if ($el.dataset[mode]) $el.innerText = $el.dataset[mode];
});
}

@ -100,14 +100,6 @@ header .cart-toggle-btn {
background-color: var(--btcpay-bg-tile);
}
#cart .quantity .btn {
width: 2rem;
height: 2rem;
}
#cart .quantity .btn .icon{
--btn-icon-size: .75rem;
}
#CartBadge {
position: absolute;
top: 0;

@ -96,3 +96,30 @@
max-height: 210px;
object-fit: scale-down;
}
.quantities .btn {
--btcpay-btn-disabled-opacity: .3;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
background: none;
border: transparent;
padding: 0;
}
.quantities .btn span {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--btcpay-light);
}
.quantities .btn:hover span {
background: var(--btcpay-light-bg-hover);
}
.quantities .icon {
width: 1rem;
height: 1rem;
}

@ -3,7 +3,7 @@
"info": {
"title": "BTCPay Greenfield API",
"version": "v1",
"description": "A full API to use your BTCPay Server",
"description": "# Introduction\n\nThe BTCPay Server Greenfield API is a REST API. Our API has predictable resource-oriented URLs, accepts form-encoded request bodies, returns JSON-encoded responses, and uses standard HTTP response codes, authentication, and verbs.\n\n# Authentication\n\nYou can authenticate either via Basic Auth or an API key. It's recommended to use an API key for better security. You can create an API key in the BTCPay Server UI under `Account` -> `Manage Account` -> `API keys`. You can restrict the API key for one or multiple stores and for specific permissions. For testing purposes, you can give it the 'Unrestricted access' permission. On production you should limit the permissions to the actual endpoints you use, you can see the required permission on the API docs at the top of each endpoint under `AUTHORIZATIONS`.\n\nIf you want to simplify the process of creating API keys for your users, you can use the [Authorization endpoint](https:\/\/docs.btcpayserver.org\/API\/Greenfield\/v1\/#tag\/Authorization) to predefine permissions and redirect your users to the BTCPay Server Authorization UI. You can find more information about this on the [API Authorization Flow docs](https:\/\/docs.btcpayserver.org\/BTCPayServer\/greenfield-authorization\/) page.\n\n# Usage examples\n\nUse **Basic Auth** to read store information with cURL:\n```bash\nBTCPAY_INSTANCE=\"https:\/\/mainnet.demo.btcpayserver.org\"\nUSER=\"MyTestUser@gmail.com\"\nPASSWORD=\"notverysecurepassword\"\nPERMISSION=\"btcpay.store.canmodifystoresettings\"\nBODY=\"$(echo \"{}\" | jq --arg \"a\" \"$PERMISSION\" '. + {permissions:[$a]}')\"\n\nAPI_KEY=\"$(curl -s \\\n -H \"Content-Type: application\/json\" \\\n --user \"$USER:$PASSWORD\" \\\n -X POST \\\n -d \"$BODY\" \\\n \"$BTCPAY_INSTANCE\/api\/v1\/api-keys\" | jq -r .apiKey)\"\n```\n\n\nUse an **API key** to read store information with cURL:\n```bash\nSTORE_ID=\"yourStoreId\"\n\ncurl -s \\\n -H \"Content-Type: application\/json\" \\\n -H \"Authorization: token $API_KEY\" \\\n -X GET \\\n \"$BTCPAY_INSTANCE\/api\/v1\/stores\/$STORE_ID\"\n```\n\nYou can find more examples on our docs for different programming languages:\n- [cURL](https:\/\/docs.btcpayserver.org\/Development\/GreenFieldExample\/)\n- [Javascript\/Node.Js](https:\/\/docs.btcpayserver.org\/Development\/GreenFieldExample-NodeJS\/)\n- [PHP](https:\/\/docs.btcpayserver.org\/Development\/GreenFieldExample-PHP\/)\n\n",
"contact": {
"name": "BTCPay Server",
"url": "https://btcpayserver.org"

@ -92,7 +92,7 @@
"Users"
],
"summary": "Create user",
"description": "Create a new user.\n\nThis operation can be called without authentication in any of this cases:\n* There is not any administrator yet on the server,\n* The subscriptions are not disabled in the server's policies.\n\nIf the first administrator is created by this call, subscriptions are automatically disabled.",
"description": "Create a new user.\n\nThis operation can be called without authentication in any of this cases:\n* There is not any administrator yet on the server,\n* User registrations are not disabled in the server's policies.\n\nIf the first administrator is created by this call, user registrations are automatically disabled.",
"requestBody": {
"x-name": "request",
"content": {
@ -149,7 +149,7 @@
"description": "If you need to authenticate for this endpoint (ie. the server settings policies lock subscriptions and that an admin already exists)"
},
"403": {
"description": "If you are authenticated but forbidden to create a new user (ie. you don't have the `unrestricted` permission on a server administrator or if you are not administrator and registrations are disabled in the server's policies)"
"description": "If you are authenticated but forbidden to create a new user (ie. you don't have the `unrestricted` permission on a server administrator or if you are not administrator and user registrations are disabled in the server's policies)"
},
"429": {
"description": "DDoS protection if you are creating more than 2 accounts every minutes (non-admin only)"

@ -1,5 +1,5 @@
<Project>
<PropertyGroup>
<Version>1.13.0</Version>
<Version>1.13.2</Version>
</PropertyGroup>
</Project>

@ -1,13 +1,52 @@
# Changelog
## 1.13.2
### New features
* Add refund reports (#5791) @NicolasDorier
* Allow `lightning:` in html hyperlinks (#6002 #6001) @dennisreimann
### Bug fixes
* If you specified a 0 amount bolt 11 invoice for a payout, it would be incorrectly validated and not accepted. (#5943 #5819) @Kukks
* Domain mapping constraint: Fix .onion case (#5948 #5917) @dennisreimann
* Pull payment QR scan fixes (#5950) @dennisreimann
* Server email settings: Fix missing password field (#5952 #5949) @dennisreimann
* Fix: Some valid taproot PSBT couldn't parsed and show better error message (#5715 #5993) @NicolasDorier
* Fix: Adding a label to a base58 addresses in the `Send Wallet` screen wasn't working (#6011) @NicolasDorier
### Improvements
* Search: Display text filters in search input (#5986 #5984) @dennisreimann
* POS: Allow overpay for articles with minimum price (#5997 #5995) @dennisreimann
* Improve data display on receipt (#5896 #5882) @dennisreimann
* Greenfield API clarifications (#5955) @ndeet
* Improvements to receipts display for PoS @rockstardev
* Fix layout on mobile on the dashboard (#5721 #6006) @dennisreimann
## 1.13.1
### Bug fixes
* Fix: CSV exports from the Reports were exporting dates in 12-hour format instead of 24-hour format. (#5915, #5922) @TChukwuleta
* Fix: Crash when configuring BTCPay Server with a non-default Postgres schema (Fix #5901) @NicolasDorier
* Fix: A payment request with an amount of 0 no longer causes the payment request's page to crash (#5926) @Kukks
### Improvements
* Prevent unintentional double payouts (#5931, #5913) @dennisreimann
* The `buyerEmail` field in a Payment Request's form will now set the email for the payment request (#5926) @Kukks
* Added Tether as a supporter to the BTCPay Server Foundation (#5891) @rockstardev
## 1.13.0
### New feature
* Server Settings: Customize instance name and add contact URL (#5718) @dennisreimann
* Server Settings: Customize instance name and add contact URL (#5718 #5872) @dennisreimann
* Admin overview of the stores on the instance (#5745 #5782) @dennisreimann @Kukks
* Onboarding: Invite new users (#5714 #5719) @dennisreimann @dstrukt
* POS: Add item list to keypad (#5814) @dennisreimann @dstrukt
* Onboarding: Invite new users (#5714 #5719 #5874) @dennisreimann @dstrukt
* POS: Add item list to keypad (#5814 #5857 #5877) @dennisreimann @dstrukt
* Wallet: Support BBQr PSBTSs (#5852) @Kukks
### Improvements
@ -17,7 +56,7 @@
* Wallet: Support 16mb PSBTs (#5768) @Kukks
* Invoice: Improve events display (#5775) @dennisreimann
* Crowdfund: Add forms (like with the POS) (#5659) @Nisaba
* API docs: Add link to API usage examples in docs (#5772) @ndeet
* API docs: Adding introduction, Authentication and Usage examples sections (#5772 #5858) @ndeet
* Policies: Cleanup and improvements (#5731) @dennisreimann @dstrukt
* Add legacy report (#5740) @Kukks
* Store: Move support URL to Checkout Appearance and improve wording (#5717) @dennisreimann
@ -33,7 +72,7 @@
* Pull Payments: When opened in mobile, use deeplink to setup card (#5613) @NicolasDorier
* UI consistency: Use toggles in various setting views (#5769) @TChukwuleta
* Wallet: Improve info message (#5756) @rockstardev
* Item Editor: Apply item changes directly (#5849) @dennisreimann
* Item Editor: Apply item changes directly (#5849 #5871) @dennisreimann
* Specify mailto: prefix for emails in Server Settings (#5844) @TChukwuleta @dennisreimann
* UI: Improve Create First Store view (#5854) @dennisreimann
* Receipts: Smaller printed receipts (#5856) @Kukks
@ -51,6 +90,7 @@
* Reports: Fix old payments not showing up in reports (#5812) @NicolasDorier
* POS: Fix exception when asking for data with a top up item (#5816) @dennisreimann
* Plugins: Do not have report name conflict with old plugin (#5826) @Kukks
* Lightning: Do not throw when local node is not synced and using external ln node (#5859) @Kukks
## 1.12.5

@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0.101-bookworm-slim AS builder
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0.203-bookworm-slim AS builder
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
WORKDIR /source
COPY nuget.config nuget.config
@ -21,7 +21,7 @@ ARG CONFIGURATION_NAME=Release
ARG GIT_COMMIT
RUN cd BTCPayServer && dotnet publish -p:GitCommit=${GIT_COMMIT} --output /app/ --configuration ${CONFIGURATION_NAME}
FROM mcr.microsoft.com/dotnet/aspnet:8.0.1-bookworm-slim
FROM mcr.microsoft.com/dotnet/aspnet:8.0.3-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends iproute2 openssh-client \
&& rm -rf /var/lib/apt/lists/*

@ -213,13 +213,15 @@ BTCPay Server software, logo and designs are provided under [MIT License](https:
The BTCPay Server Project is proudly supported by these entities through the [BTCPay Server Foundation](https://foundation.btcpayserver.org/).
[![Spiral](BTCPayServer/wwwroot/img/readme/supporter_spiral.svg)](https://spiral.xyz)
[![Baillie Gifford](BTCPayServer/wwwroot/img/readme/supporter_bailliegifford.svg)](https://www.bailliegifford.com)
[![Strike](BTCPayServer/wwwroot/img/readme/supporter_strike.svg)](https://strike.me)
[![Human Rights Foundation](BTCPayServer/wwwroot/img/readme/supporter_hrf.svg)](https://hrf.org)
[![LunaNode](BTCPayServer/wwwroot/img/readme/supporter_lunanode.svg)](https://lunanode.com)
[![Wallet of Satoshi](BTCPayServer/wwwroot/img/readme/supporter_walletofsatoshi.svg)](https://walletofsatoshi.com/)
[![Coincards](BTCPayServer/wwwroot/img/readme/supporter_coincards.svg)](https://coincards.com/)
[![IVPN](BTCPayServer/wwwroot/img/readme/supporter_ivpn.svg)](https://ivpn.net/)
[![Spiral](https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/img/readme/supporter_spiral.svg)](https://spiral.xyz)
[![OpenSats](https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/img/readme/supporter_opensats.svg)](https://opensats.org)
[![Baillie Gifford](https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/img/readme/supporter_bailliegifford.svg)](https://www.bailliegifford.com)
[![Tether](https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/img/readme/supporter_tether.svg)](https://tether.to)
[![Human Rights Foundation](https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/img/readme/supporter_hrf.svg)](https://hrf.org)
[![Strike](https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/img/readme/supporter_strike.svg)](https://strike.me)
[![LunaNode](https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/img/readme/supporter_lunanode.svg)](https://lunanode.com)
[![Wallet of Satoshi](https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/img/readme/supporter_walletofsatoshi.svg)](https://walletofsatoshi.com/)
[![Coincards](https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/img/readme/supporter_coincards.svg)](https://coincards.com/)
[![IVPN](https://raw.githubusercontent.com/btcpayserver/btcpayserver/master/BTCPayServer/wwwroot/img/readme/supporter_ivpn.svg)](https://ivpn.net/)
If you'd like to support the project, please visit the [donation page](https://btcpayserver.org/donate/).

@ -1,24 +1,33 @@
# GreenField API Authorization Flow
The GreenField API allows two modes of authentication to its endpoints: Basic auth and API Keys.
The GreenField API allows two modes of authentication to its endpoints: Basic Auth and API keys.
## Basic auth
Basic auth allows you to seamlessly integrate with BTCPay Server's user system using only a traditional user/password login form. This is however a security risk if the application is a third party as they will receive your credentials in plain text and will be able to access your full account.
## API Keys
BTCPay Server's GreenField API also allows users to generate API keys with [specific permissions](https://docs.btcpayserver.org/API/Greenfield/v1/#section/Authentication/API%20Key). **If you are integrating BTCPay Server into your third-party application, this is the recommended way.**
BTCPay Server's Greenfield API also allows users to generate API keys with [specific permissions](https://docs.btcpayserver.org/API/Greenfield/v1/#section/Authentication/API_Key). **If you are integrating BTCPay Server into your third-party application, this is the recommended way.**
Asking a user to generate a dedicated API key, with a specific set of permissions can be a bad UX experience. For this scenario, we have the [Authorize User UI](https://docs.btcpayserver.org/API/Greenfield/v1/#tag/Authorization). This allows external applications to request the user to generate an API key with a specific set of permissions by simply generating a URL to BTCPay Server and redirecting the user to it.
### Manually create an API key
Users can create a new API key in the BTCPay Server UI under `Account` -> `Manage account` -> `API keys`
### Create API keys over the API itself
A user can create an API key for themselves using the [Create API Key endpoint](https://docs.btcpayserver.org/API/Greenfield/v1/#operation/APIKeys_CreateAPIKey) via Basic Auth or an unrestricted API key. Server administrators can create API keys for any user using the [Create API key for user endpoint](https://docs.btcpayserver.org/API/Greenfield/v1/#operation/ApiKeys_CreateUserApiKey).
### Interactive API key setup flow
Asking a user to generate a dedicated API key, with a specific set of permissions manually can be a bad UX experience. For this scenario, we have the [Authorize User UI](https://docs.btcpayserver.org/API/Greenfield/v1/#tag/Authorization). This allows external applications to request the user to generate an API key with a specific set of permissions by simply generating a URL to BTCPay Server and redirecting the user to it.
Additionally, there are 2 optional parameters to the endpoint which allow a more seamless integration:
* if `redirect` is specified, once the API key is created, BTCPay Server redirects the user via a POST submission to the specified `redirect` URL, with a json body containing the API key, user id, and permissions granted.
* if `applicationIdentifier` is specified (along with `redirect`), BTCPay Server will check if there is an existing API key associated with the user that also has this application identifier, redirect host AND the permissions required match. `applicationIdentifier` is ignored if `redirect` is not specified.
Some examples of a generated Authorize URL:
* `https://mainnet.demo.btcpayserver.org/api-keys/authorize` - A simplistic request, where no permission is requested. Useful to prove that a user exists on a specific BTCPay Server instance.
* `https://mainnet.demo.btcpayserver.org/api-keys/authorize` - A simplistic request, where no permission is requested. Useful to prove that a user exists on a specific BTCPay Server instance.
* `https://mainnet.demo.btcpayserver.org/api-keys/authorize?applicationName=Your%20Application` - Indicates that the API key is being generated for `Your Application`
* `https://mainnet.demo.btcpayserver.org/api-keys/authorize?applicationName=Your%20Application&redirect=http://gozo.com` - Redirects the user via a POST to `http://gozo.com` with a JSON body containing the API key and its info.
* `https://mainnet.demo.btcpayserver.org/api-keys/authorize?applicationName=Your%20Application&redirect=http://gozo.com&applicationIdentifier=gozo` - Attempts to match a previously created API key based on the app identifier, domain and permissions and is prompted.
* `https://mainnet.demo.btcpayserver.org/api-keys/authorize?permissions=btcpay.store.cancreateinvoice&permissions=btcpay.store.canviewinvoices` - A request asking for permissions to create and view invoices on all stores available to the user
* `https://mainnet.demo.btcpayserver.org/api-keys/authorize?permissions=btcpay.store.cancreateinvoice&permissions=btcpay.store.canviewinvoices&selectiveStores=true` - A request asking for permissions to create and view invoices on stores but also allows the user to choose which stores the application will have the permission to.
* `https://mainnet.demo.btcpayserver.org/api-keys/authorize?permissions=btcpay.store.cancreateinvoice&permissions=btcpay.store.canviewinvoices&strict=false` - A request asking for permissions but allows the user to remove or add to the requested permission list.
* `https://mainnet.demo.btcpayserver.org/api-keys/authorize?permissions=btcpay.store.cancreateinvoice&permissions=btcpay.store.canviewinvoices&strict=false` - A request asking for permissions but allows the user to remove or add to the requested permission list.