Compare commits
30 Commits
v1.13.1
...
v1.13.2-rc
Author | SHA1 | Date | |
---|---|---|---|
8356c0d5e5 | |||
6d2f886717 | |||
f9aae4ab3d | |||
1ecf0d25a9 | |||
c7231fe092 | |||
8922c3de59 | |||
fefb99dfa2 | |||
a3b0bbe861 | |||
3dd562ffdc | |||
b19db7291d | |||
70253cbd9f | |||
887803a328 | |||
42da90f7dc | |||
1152f68aed | |||
9124aeb1ee | |||
a35c5d8289 | |||
e24b42ef95 | |||
e10937c253 | |||
96b90d2444 | |||
600bbb9ce0 | |||
fe9e5eb9c9 | |||
cb136cba82 | |||
b3240f28b5 | |||
fe32cbd8be | |||
51fcf52da1 | |||
3f02c0d30a | |||
bae1f4e20b | |||
3fbc717cd4 | |||
958a348fed | |||
57226fc97f |
BTCPayServer.Abstractions
BTCPayServer.Client
BTCPayServer.Common
BTCPayServer.Data
BTCPayServer.Rating
BTCPayServer.Tests
BTCPayServer
BTCPayServer.csproj
Components
Controllers
GreenField
UIInvoiceController.UI.csUILNURLController.csUIPullPaymentController.csUIReportsController.CheatMode.csUIWalletsController.PSBT.csUIWalletsController.csData/Payouts/LightningLike
Filters
HostedServices
Hosting
Models
InvoicingModels
WalletViewModels
Plugins/Shopify
OrderTransactionRegisterLogic.csShopifyApiClient.csShopifyOrderMarkerHostedService.csShopifyPlugin.csShopifyService.csUIShopifyController.cs
SearchString.csServices
Views
Shared
UIInvoice
UIPaymentRequest
UIPullPayment
UIReports
UIServer
UIStorePullPayments
UIStores
wwwroot
Build
Changelog.mdDockerfiledocs
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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"
|
||||
@ -226,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"
|
||||
@ -261,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"
|
||||
@ -213,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"
|
||||
@ -250,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.6.0" />
|
||||
<PackageReference Include="CsvHelper" Version="15.0.5" />
|
||||
<PackageReference Include="Dapper" Version="2.1.28" />
|
||||
<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>
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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; }
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -1,87 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Plugins.Shopify.ApiModels;
|
||||
|
||||
namespace BTCPayServer.Plugins.Shopify
|
||||
{
|
||||
public class OrderTransactionRegisterLogic
|
||||
{
|
||||
private readonly ShopifyApiClient _client;
|
||||
|
||||
public OrderTransactionRegisterLogic(ShopifyApiClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
private static string[] _keywords = new[] { "bitcoin", "btc", "btcpayserver", "btcpay server" };
|
||||
public async Task<TransactionsCreateResp> Process(string orderId, string invoiceId, string currency, string amountCaptured, bool success)
|
||||
{
|
||||
currency = currency.ToUpperInvariant().Trim();
|
||||
var existingShopifyOrderTransactions = (await _client.TransactionsList(orderId)).transactions;
|
||||
|
||||
//if there isn't a record for btcpay payment gateway, abort
|
||||
var baseParentTransaction = existingShopifyOrderTransactions.FirstOrDefault(holder => _keywords.Any(a => holder.gateway.Contains(a, StringComparison.InvariantCultureIgnoreCase)));
|
||||
if (baseParentTransaction is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
//technically, this exploit should not be possible as we use internal invoice tags to verify that the invoice was created by our controlled, dedicated endpoint.
|
||||
if (currency.ToUpperInvariant().Trim() != baseParentTransaction.currency.ToUpperInvariant().Trim())
|
||||
{
|
||||
// because of parent_id present, currency will always be the one from parent transaction
|
||||
// malicious attacker could potentially exploit this by creating invoice
|
||||
// in different currency and paying that one, registering order on Shopify as paid
|
||||
// so if currency is supplied and is different from parent transaction currency we just won't register
|
||||
return null;
|
||||
}
|
||||
|
||||
var kind = "capture";
|
||||
var parentId = baseParentTransaction.id;
|
||||
var status = success ? "success" : "failure";
|
||||
//find all existing transactions recorded around this invoice id
|
||||
var existingShopifyOrderTransactionsOnSameInvoice =
|
||||
existingShopifyOrderTransactions.Where(holder => holder.authorization == invoiceId);
|
||||
|
||||
//filter out the successful ones
|
||||
var successfulActions =
|
||||
existingShopifyOrderTransactionsOnSameInvoice.Where(holder => holder.status == "success").ToArray();
|
||||
|
||||
//of the successful ones, get the ones we registered as a valid payment
|
||||
var successfulCaptures = successfulActions.Where(holder => holder.kind == "capture").ToArray();
|
||||
|
||||
//of the successful ones, get the ones we registered as a voiding of a previous successful payment
|
||||
var refunds = successfulActions.Where(holder => holder.kind == "refund").ToArray();
|
||||
|
||||
//if we are working with a non-success registration, but see that we have previously registered this invoice as a success, we switch to creating a "void" transaction, which in shopify terms is a refund.
|
||||
if (!success && successfulCaptures.Length > 0 && (successfulCaptures.Length - refunds.Length) > 0)
|
||||
{
|
||||
kind = "void";
|
||||
parentId = successfulCaptures.Last().id;
|
||||
status = "success";
|
||||
}
|
||||
//if we are working with a success registration, but can see that we have already had a successful transaction saved, get outta here
|
||||
else if (success && successfulCaptures.Length > 0 && (successfulCaptures.Length - refunds.Length) > 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var createTransaction = new TransactionsCreateReq
|
||||
{
|
||||
transaction = new TransactionsCreateReq.DataHolder
|
||||
{
|
||||
parent_id = parentId,
|
||||
currency = currency,
|
||||
amount = amountCaptured,
|
||||
kind = kind,
|
||||
gateway = "BTCPayServer",
|
||||
source = "external",
|
||||
authorization = invoiceId,
|
||||
status = status
|
||||
}
|
||||
};
|
||||
var createResp = await _client.TransactionCreate(orderId, createTransaction);
|
||||
return createResp;
|
||||
}
|
||||
}
|
||||
}
|
@ -35,10 +35,10 @@ namespace BTCPayServer.Plugins.Shopify
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateRequest(string shopName, HttpMethod method, string action,
|
||||
string relativeUrl = null)
|
||||
string relativeUrl = null, string apiVersion = "2020-07")
|
||||
{
|
||||
var url =
|
||||
$"https://{(shopName.Contains('.', StringComparison.InvariantCulture) ? shopName : $"{shopName}.myshopify.com")}/{relativeUrl ?? ("admin/api/2020-07/" + action)}";
|
||||
$"https://{(shopName.Contains('.', StringComparison.InvariantCulture) ? shopName : $"{shopName}.myshopify.com")}/{relativeUrl ?? ($"admin/api/{apiVersion}/" + action)}";
|
||||
var req = new HttpRequestMessage(method, url);
|
||||
return req;
|
||||
}
|
||||
@ -115,6 +115,15 @@ namespace BTCPayServer.Plugins.Shopify
|
||||
|
||||
return JObject.Parse(strResp)["order"].ToObject<ShopifyOrder>();
|
||||
}
|
||||
public async Task<ShopifyOrder> CancelOrder(string orderId)
|
||||
{
|
||||
var req = CreateRequest(_credentials.ShopName, HttpMethod.Post,
|
||||
$"orders/{orderId}/close.json", null, "2024-04");
|
||||
|
||||
var strResp = await SendRequest(req);
|
||||
|
||||
return JObject.Parse(strResp)["order"].ToObject<ShopifyOrder>();
|
||||
}
|
||||
|
||||
public async Task<long> OrdersCount()
|
||||
{
|
||||
|
@ -1,116 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Plugins.Shopify.Models;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace BTCPayServer.Plugins.Shopify
|
||||
{
|
||||
public class ShopifyOrderMarkerHostedService : EventHostedServiceBase
|
||||
{
|
||||
private readonly StoreRepository _storeRepository;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public ShopifyOrderMarkerHostedService(EventAggregator eventAggregator,
|
||||
StoreRepository storeRepository,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
Logs logs) : base(eventAggregator, logs)
|
||||
{
|
||||
_storeRepository = storeRepository;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
public const string SHOPIFY_ORDER_ID_PREFIX = "shopify-";
|
||||
|
||||
protected override void SubscribeToEvents()
|
||||
{
|
||||
Subscribe<InvoiceEvent>();
|
||||
base.SubscribeToEvents();
|
||||
}
|
||||
|
||||
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
||||
{
|
||||
if (evt is InvoiceEvent invoiceEvent && !new[]
|
||||
{
|
||||
InvoiceEvent.Created, InvoiceEvent.ExpiredPaidPartial,
|
||||
InvoiceEvent.ReceivedPayment, InvoiceEvent.PaidInFull
|
||||
}.Contains(invoiceEvent.Name))
|
||||
{
|
||||
var invoice = invoiceEvent.Invoice;
|
||||
var shopifyOrderId = invoice.GetInternalTags(SHOPIFY_ORDER_ID_PREFIX).FirstOrDefault();
|
||||
if (shopifyOrderId != null)
|
||||
{
|
||||
if (new[] { InvoiceStatusLegacy.Invalid, InvoiceStatusLegacy.Expired }.Contains(invoice.GetInvoiceState()
|
||||
.Status) && invoice.ExceptionStatus != InvoiceExceptionStatus.None)
|
||||
{
|
||||
//you have failed us, customer
|
||||
|
||||
await RegisterTransaction(invoice, shopifyOrderId, false);
|
||||
}
|
||||
else if (new[] { InvoiceStatusLegacy.Complete, InvoiceStatusLegacy.Confirmed }.Contains(
|
||||
invoice.Status))
|
||||
{
|
||||
await RegisterTransaction(invoice, shopifyOrderId, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await base.ProcessEvent(evt, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task RegisterTransaction(InvoiceEntity invoice, string shopifyOrderId, bool success)
|
||||
{
|
||||
var storeData = await _storeRepository.FindStore(invoice.StoreId);
|
||||
var storeBlob = storeData.GetStoreBlob();
|
||||
|
||||
// ensure that store in question has shopify integration turned on
|
||||
// and that invoice's orderId has shopify specific prefix
|
||||
var settings = storeBlob.GetShopifySettings();
|
||||
if (settings?.IntegratedAt.HasValue == true)
|
||||
{
|
||||
var client = CreateShopifyApiClient(settings);
|
||||
if (!await client.OrderExists(shopifyOrderId))
|
||||
{
|
||||
// don't register transactions for orders that don't exist on shopify
|
||||
return;
|
||||
}
|
||||
|
||||
// if we got this far, we likely need to register this invoice's payment on Shopify
|
||||
// OrderTransactionRegisterLogic has check if transaction is already registered which is why we're passing invoice.Id
|
||||
try
|
||||
{
|
||||
var logic = new OrderTransactionRegisterLogic(client);
|
||||
var resp = await logic.Process(shopifyOrderId, invoice.Id, invoice.Currency,
|
||||
invoice.Price.ToString(CultureInfo.InvariantCulture), success);
|
||||
if (resp != null)
|
||||
{
|
||||
Logs.PayServer.LogInformation($"Registered order transaction {invoice.Price}{invoice.Currency} on Shopify. " +
|
||||
$"Triggered by invoiceId: {invoice.Id}, Shopify orderId: {shopifyOrderId}, Success: {success}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.PayServer.LogError(ex,
|
||||
$"Shopify error while trying to register order transaction. " +
|
||||
$"Triggered by invoiceId: {invoice.Id}, Shopify orderId: {shopifyOrderId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private ShopifyApiClient CreateShopifyApiClient(ShopifySettings shopify)
|
||||
{
|
||||
return new ShopifyApiClient(_httpClientFactory, shopify.CreateShopifyApiCredentials());
|
||||
}
|
||||
}
|
||||
}
|
@ -14,7 +14,8 @@ namespace BTCPayServer.Plugins.Shopify
|
||||
|
||||
public override void Execute(IServiceCollection applicationBuilder)
|
||||
{
|
||||
applicationBuilder.AddSingleton<IHostedService, ShopifyOrderMarkerHostedService>();
|
||||
applicationBuilder.AddSingleton<ShopifyService>();
|
||||
applicationBuilder.AddSingleton<IHostedService, ShopifyService>(provider => provider.GetRequiredService<ShopifyService>());
|
||||
applicationBuilder.AddSingleton<IUIExtension>(new UIExtension("Shopify/NavExtension", "header-nav"));
|
||||
base.Execute(applicationBuilder);
|
||||
}
|
||||
|
231
BTCPayServer/Plugins/Shopify/ShopifyService.cs
Normal file
231
BTCPayServer/Plugins/Shopify/ShopifyService.cs
Normal file
@ -0,0 +1,231 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Plugins.Shopify.ApiModels;
|
||||
using BTCPayServer.Plugins.Shopify.Models;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace BTCPayServer.Plugins.Shopify
|
||||
{
|
||||
public class ShopifyService : EventHostedServiceBase
|
||||
{
|
||||
private readonly StoreRepository _storeRepository;
|
||||
private readonly InvoiceRepository _invoiceRepository;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public ShopifyService(EventAggregator eventAggregator,
|
||||
StoreRepository storeRepository,
|
||||
InvoiceRepository invoiceRepository,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
Logs logs) : base(eventAggregator, logs)
|
||||
{
|
||||
_storeRepository = storeRepository;
|
||||
_invoiceRepository = invoiceRepository;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
public const string SHOPIFY_ORDER_ID_PREFIX = "shopify-";
|
||||
|
||||
protected override void SubscribeToEvents()
|
||||
{
|
||||
Subscribe<InvoiceEvent>();
|
||||
base.SubscribeToEvents();
|
||||
}
|
||||
|
||||
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
||||
{
|
||||
if (evt is InvoiceEvent invoiceEvent && !new[]
|
||||
{
|
||||
InvoiceEvent.MarkedCompleted, InvoiceEvent.MarkedInvalid, InvoiceEvent.Expired,
|
||||
InvoiceEvent.Completed
|
||||
}.Contains(invoiceEvent.Name))
|
||||
{
|
||||
var invoice = invoiceEvent.Invoice;
|
||||
var shopifyOrderId = invoice.GetInternalTags(SHOPIFY_ORDER_ID_PREFIX).FirstOrDefault();
|
||||
if (shopifyOrderId != null)
|
||||
{
|
||||
var success = invoice.Status.ToModernStatus() switch
|
||||
{
|
||||
InvoiceStatus.Settled => true,
|
||||
InvoiceStatus.Invalid or InvoiceStatus.Expired => false,
|
||||
_ => (bool?)null
|
||||
};
|
||||
|
||||
if (success.HasValue)
|
||||
await RegisterTransaction(invoice, shopifyOrderId, success.Value);
|
||||
}
|
||||
}
|
||||
|
||||
await base.ProcessEvent(evt, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task RegisterTransaction(InvoiceEntity invoice, string shopifyOrderId, bool success)
|
||||
{
|
||||
var storeData = await _storeRepository.FindStore(invoice.StoreId);
|
||||
var storeBlob = storeData.GetStoreBlob();
|
||||
|
||||
// ensure that store in question has shopify integration turned on
|
||||
// and that invoice's orderId has shopify specific prefix
|
||||
var settings = storeBlob.GetShopifySettings();
|
||||
if (settings?.IntegratedAt.HasValue == true)
|
||||
{
|
||||
var client = CreateShopifyApiClient(settings);
|
||||
if (!await client.OrderExists(shopifyOrderId))
|
||||
{
|
||||
// don't register transactions for orders that don't exist on shopify
|
||||
return;
|
||||
}
|
||||
|
||||
// if we got this far, we likely need to register this invoice's payment on Shopify
|
||||
// OrderTransactionRegisterLogic has check if transaction is already registered which is why we're passing invoice.Id
|
||||
try
|
||||
{
|
||||
var resp = await Process(client, shopifyOrderId, invoice.Id, invoice.Currency,
|
||||
invoice.Price.ToString(CultureInfo.InvariantCulture), success);
|
||||
if (resp != null)
|
||||
{
|
||||
await _invoiceRepository.AddInvoiceLogs(invoice.Id, resp);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.PayServer.LogError(ex,
|
||||
$"Shopify error while trying to register order transaction. " +
|
||||
$"Triggered by invoiceId: {invoice.Id}, Shopify orderId: {shopifyOrderId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private ShopifyApiClient CreateShopifyApiClient(ShopifySettings shopify)
|
||||
{
|
||||
return new ShopifyApiClient(_httpClientFactory, shopify.CreateShopifyApiCredentials());
|
||||
}
|
||||
|
||||
|
||||
private static string[] _keywords = new[] {"bitcoin", "btc", "btcpayserver", "btcpay server"};
|
||||
|
||||
public async Task<InvoiceLogs> Process(ShopifyApiClient client, string orderId, string invoiceId,
|
||||
string currency, string amountCaptured, bool success)
|
||||
{
|
||||
var result = new InvoiceLogs();
|
||||
currency = currency.ToUpperInvariant().Trim();
|
||||
var existingShopifyOrderTransactions = (await client.TransactionsList(orderId)).transactions;
|
||||
|
||||
//if there isn't a record for btcpay payment gateway, abort
|
||||
var baseParentTransaction = existingShopifyOrderTransactions.FirstOrDefault(holder =>
|
||||
_keywords.Any(a => holder.gateway.Contains(a, StringComparison.InvariantCultureIgnoreCase)));
|
||||
if (baseParentTransaction is null)
|
||||
{
|
||||
result.Write("Couldn't find the order on Shopify.", InvoiceEventData.EventSeverity.Error);
|
||||
return result;
|
||||
}
|
||||
|
||||
//technically, this exploit should not be possible as we use internal invoice tags to verify that the invoice was created by our controlled, dedicated endpoint.
|
||||
if (currency.ToUpperInvariant().Trim() != baseParentTransaction.currency.ToUpperInvariant().Trim())
|
||||
{
|
||||
// because of parent_id present, currency will always be the one from parent transaction
|
||||
// malicious attacker could potentially exploit this by creating invoice
|
||||
// in different currency and paying that one, registering order on Shopify as paid
|
||||
// so if currency is supplied and is different from parent transaction currency we just won't register
|
||||
result.Write("Currency mismatch on Shopify.", InvoiceEventData.EventSeverity.Error);
|
||||
return result;
|
||||
}
|
||||
|
||||
var kind = "capture";
|
||||
var parentId = baseParentTransaction.id;
|
||||
var status = success ? "success" : "failure";
|
||||
//find all existing transactions recorded around this invoice id
|
||||
var existingShopifyOrderTransactionsOnSameInvoice =
|
||||
existingShopifyOrderTransactions.Where(holder => holder.authorization == invoiceId);
|
||||
|
||||
//filter out the successful ones
|
||||
var successfulActions =
|
||||
existingShopifyOrderTransactionsOnSameInvoice.Where(holder => holder.status == "success").ToArray();
|
||||
|
||||
//of the successful ones, get the ones we registered as a valid payment
|
||||
var successfulCaptures = successfulActions.Where(holder => holder.kind == "capture").ToArray();
|
||||
|
||||
//of the successful ones, get the ones we registered as a voiding of a previous successful payment
|
||||
var refunds = successfulActions.Where(holder => holder.kind == "refund").ToArray();
|
||||
|
||||
//if we are working with a non-success registration, but see that we have previously registered this invoice as a success, we switch to creating a "void" transaction, which in shopify terms is a refund.
|
||||
if (!success && successfulCaptures.Length > 0 && (successfulCaptures.Length - refunds.Length) > 0)
|
||||
{
|
||||
kind = "void";
|
||||
parentId = successfulCaptures.Last().id;
|
||||
status = "success";
|
||||
result.Write(
|
||||
"A transaction was previously recorded against the Shopify order. Creating a void transaction.",
|
||||
InvoiceEventData.EventSeverity.Warning);
|
||||
}
|
||||
else if (!success)
|
||||
{
|
||||
kind = "void";
|
||||
status = "success";
|
||||
result.Write("Attempting to void the payment on Shopify order due to failure in payment.",
|
||||
InvoiceEventData.EventSeverity.Warning);
|
||||
}
|
||||
//if we are working with a success registration, but can see that we have already had a successful transaction saved, get outta here
|
||||
else if (success && successfulCaptures.Length > 0 && (successfulCaptures.Length - refunds.Length) > 0)
|
||||
{
|
||||
result.Write("A transaction was previously recorded against the Shopify order. Skipping.",
|
||||
InvoiceEventData.EventSeverity.Warning);
|
||||
return result;
|
||||
}
|
||||
|
||||
var createTransaction = new TransactionsCreateReq
|
||||
{
|
||||
transaction = new TransactionsCreateReq.DataHolder
|
||||
{
|
||||
parent_id = parentId,
|
||||
currency = currency,
|
||||
amount = amountCaptured,
|
||||
kind = kind,
|
||||
gateway = "BTCPayServer",
|
||||
source = "external",
|
||||
authorization = invoiceId,
|
||||
status = status
|
||||
}
|
||||
};
|
||||
var createResp = await client.TransactionCreate(orderId, createTransaction);
|
||||
|
||||
if (createResp.transaction is null)
|
||||
{
|
||||
result.Write("Failed to register the transaction on Shopify.", InvoiceEventData.EventSeverity.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Write(
|
||||
$"Successfully registered the transaction on Shopify. tx status:{createResp.transaction.status}, kind: {createResp.transaction.kind}, order id:{createResp.transaction.order_id}",
|
||||
InvoiceEventData.EventSeverity.Info);
|
||||
}
|
||||
|
||||
if (!success)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.CancelOrder(orderId);
|
||||
result.Write("Cancelling the Shopify order.", InvoiceEventData.EventSeverity.Warning);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
result.Write($"Failed to cancel the Shopify order. {e.Message}",
|
||||
InvoiceEventData.EventSeverity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
@ -34,6 +34,7 @@ namespace BTCPayServer.Plugins.Shopify
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public class UIShopifyController : Controller
|
||||
{
|
||||
private readonly ShopifyService _shopifyService;
|
||||
private readonly BTCPayServerEnvironment _btcPayServerEnvironment;
|
||||
private readonly IOptions<BTCPayServerOptions> _btcPayServerOptions;
|
||||
private readonly IWebHostEnvironment _webHostEnvironment;
|
||||
@ -43,7 +44,9 @@ namespace BTCPayServer.Plugins.Shopify
|
||||
private readonly IJsonHelper _jsonHelper;
|
||||
private readonly IHttpClientFactory _clientFactory;
|
||||
|
||||
public UIShopifyController(BTCPayServerEnvironment btcPayServerEnvironment,
|
||||
public UIShopifyController(
|
||||
ShopifyService shopifyService,
|
||||
BTCPayServerEnvironment btcPayServerEnvironment,
|
||||
IOptions<BTCPayServerOptions> btcPayServerOptions,
|
||||
IWebHostEnvironment webHostEnvironment,
|
||||
StoreRepository storeRepository,
|
||||
@ -52,6 +55,7 @@ namespace BTCPayServer.Plugins.Shopify
|
||||
IJsonHelper jsonHelper,
|
||||
IHttpClientFactory clientFactory)
|
||||
{
|
||||
_shopifyService = shopifyService;
|
||||
_btcPayServerEnvironment = btcPayServerEnvironment;
|
||||
_btcPayServerOptions = btcPayServerOptions;
|
||||
_webHostEnvironment = webHostEnvironment;
|
||||
@ -106,14 +110,14 @@ namespace BTCPayServer.Plugins.Shopify
|
||||
public async Task<IActionResult> ShopifyInvoiceEndpoint(
|
||||
string storeId, string orderId, decimal amount, bool checkOnly = false)
|
||||
{
|
||||
var shopifySearchTerm = $"{ShopifyOrderMarkerHostedService.SHOPIFY_ORDER_ID_PREFIX}{orderId}";
|
||||
var shopifySearchTerm = $"{ShopifyService.SHOPIFY_ORDER_ID_PREFIX}{orderId}";
|
||||
var matchedExistingInvoices = await _invoiceRepository.GetInvoices(new InvoiceQuery()
|
||||
{
|
||||
TextSearch = shopifySearchTerm,
|
||||
StoreId = new[] { storeId }
|
||||
});
|
||||
matchedExistingInvoices = matchedExistingInvoices.Where(entity =>
|
||||
entity.GetInternalTags(ShopifyOrderMarkerHostedService.SHOPIFY_ORDER_ID_PREFIX)
|
||||
entity.GetInternalTags(ShopifyService.SHOPIFY_ORDER_ID_PREFIX)
|
||||
.Any(s => s == orderId))
|
||||
.ToArray();
|
||||
|
||||
@ -155,7 +159,7 @@ namespace BTCPayServer.Plugins.Shopify
|
||||
if (client != null && order?.FinancialStatus == "pending" &&
|
||||
firstInvoiceSettled.Status != InvoiceStatusLegacy.Paid)
|
||||
{
|
||||
await new OrderTransactionRegisterLogic(client).Process(orderId, firstInvoiceSettled.Id,
|
||||
await _shopifyService.Process(client, orderId, firstInvoiceSettled.Id,
|
||||
firstInvoiceSettled.Currency,
|
||||
firstInvoiceSettled.Price.ToString(CultureInfo.InvariantCulture), true);
|
||||
order = await client.GetOrder(orderId);
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
133
BTCPayServer/Services/Reporting/RefundsReportProvider.cs
Normal file
133
BTCPayServer/Services/Reporting/RefundsReportProvider.cs
Normal file
@ -0,0 +1,133 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace BTCPayServer.Services.Reporting
|
||||
{
|
||||
public class RefundsReportProvider : ReportProvider
|
||||
{
|
||||
private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings;
|
||||
private readonly DisplayFormatter _displayFormatter;
|
||||
|
||||
private ViewDefinition CreateDefinition()
|
||||
{
|
||||
return new ViewDefinition
|
||||
{
|
||||
Fields = new List<StoreReportResponse.Field>
|
||||
{
|
||||
new("Date", "datetime"),
|
||||
new("InvoiceId", "invoice_id"),
|
||||
new("Currency", "string"),
|
||||
new("Completed", "amount"),
|
||||
new("Awaiting", "amount"),
|
||||
new("Limit", "amount"),
|
||||
new("FullyPaid", "boolean")
|
||||
},
|
||||
Charts =
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Name = "Aggregated amount",
|
||||
Groups = { "Currency" },
|
||||
HasGrandTotal = false,
|
||||
Aggregates = { "Awaiting", "Completed", "Limit" }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
public override string Name => "Refunds";
|
||||
|
||||
public ApplicationDbContextFactory DbContextFactory { get; }
|
||||
|
||||
public RefundsReportProvider(
|
||||
ApplicationDbContextFactory dbContextFactory,
|
||||
BTCPayNetworkJsonSerializerSettings serializerSettings,
|
||||
DisplayFormatter displayFormatter)
|
||||
{
|
||||
DbContextFactory = dbContextFactory;
|
||||
_serializerSettings = serializerSettings;
|
||||
_displayFormatter = displayFormatter;
|
||||
}
|
||||
record RefundRow(DateTimeOffset Created, string InvoiceId, string PullPaymentId, string Currency, decimal Limit)
|
||||
{
|
||||
public decimal Completed { get; set; }
|
||||
public decimal Awaiting { get; set; }
|
||||
}
|
||||
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
|
||||
{
|
||||
queryContext.ViewDefinition = CreateDefinition();
|
||||
RefundRow? currentRow = null;
|
||||
await using var ctx = DbContextFactory.CreateContext();
|
||||
var conn = ctx.Database.GetDbConnection();
|
||||
var rows = await conn.QueryAsync(
|
||||
"""
|
||||
SELECT i."Created", i."Id" AS "InvoiceId", p."State", p."PaymentMethodId", pp."Id" AS "PullPaymentId", pp."Blob" AS "ppBlob", p."Blob" AS "pBlob" FROM "Invoices" i
|
||||
JOIN "Refunds" r ON r."InvoiceDataId"= i."Id"
|
||||
JOIN "PullPayments" pp ON r."PullPaymentDataId"=pp."Id"
|
||||
LEFT JOIN "Payouts" p ON p."PullPaymentDataId"=pp."Id"
|
||||
WHERE i."StoreDataId" = @storeId
|
||||
AND i."Created" >= @start AND i."Created" <= @end
|
||||
AND pp."Archived" IS FALSE
|
||||
ORDER BY i."Created", pp."Id"
|
||||
""", new { start = queryContext.From, end = queryContext.To, storeId = queryContext.StoreId });
|
||||
foreach (var r in rows)
|
||||
{
|
||||
PullPaymentBlob ppBlob = GetPullPaymentBlob(r);
|
||||
PayoutBlob? pBlob = GetPayoutBlob(r);
|
||||
|
||||
if ((string)r.PullPaymentId != currentRow?.PullPaymentId)
|
||||
{
|
||||
AddRow(queryContext, currentRow);
|
||||
currentRow = new(r.Created, r.InvoiceId, r.PullPaymentId, ppBlob.Currency, ppBlob.Limit);
|
||||
}
|
||||
if (pBlob is null)
|
||||
continue;
|
||||
var state = Enum.Parse<PayoutState>((string)r.State);
|
||||
if (state == PayoutState.Cancelled)
|
||||
continue;
|
||||
if (state is PayoutState.Completed)
|
||||
currentRow.Completed += pBlob.Amount;
|
||||
else
|
||||
currentRow.Awaiting += pBlob.Amount;
|
||||
}
|
||||
AddRow(queryContext, currentRow);
|
||||
}
|
||||
|
||||
private PayoutBlob? GetPayoutBlob(dynamic r)
|
||||
{
|
||||
if (r.pBlob is null)
|
||||
return null;
|
||||
Data.PayoutData p = new Data.PayoutData();
|
||||
p.PaymentMethodId = r.PaymentMethodId;
|
||||
p.Blob = (string)r.pBlob;
|
||||
return p.GetBlob(_serializerSettings);
|
||||
}
|
||||
|
||||
private static PullPaymentBlob GetPullPaymentBlob(dynamic r)
|
||||
{
|
||||
Data.PullPaymentData pp = new Data.PullPaymentData();
|
||||
pp.Blob = (string)r.ppBlob;
|
||||
return pp.GetBlob();
|
||||
}
|
||||
|
||||
private void AddRow(QueryContext queryContext, RefundRow? currentRow)
|
||||
{
|
||||
if (currentRow is null)
|
||||
return;
|
||||
var data = queryContext.AddData();
|
||||
data.Add(currentRow.Created);
|
||||
data.Add(currentRow.InvoiceId);
|
||||
data.Add(currentRow.Currency);
|
||||
data.Add(_displayFormatter.ToFormattedAmount(currentRow.Completed, currentRow.Currency));
|
||||
data.Add(_displayFormatter.ToFormattedAmount(currentRow.Awaiting, currentRow.Currency));
|
||||
data.Add(_displayFormatter.ToFormattedAmount(currentRow.Limit, currentRow.Currency));
|
||||
data.Add(currentRow.Limit <= currentRow.Completed);
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
||||
|
@ -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
|
||||
|
@ -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" />
|
||||
|
@ -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" />
|
||||
|
@ -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" />
|
||||
|
@ -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];
|
||||
});
|
||||
}
|
||||
|
@ -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.1</Version>
|
||||
<Version>1.13.2</Version>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
26
Changelog.md
26
Changelog.md
@ -1,5 +1,31 @@
|
||||
# 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
|
||||
* Fix: When an invoice expires, the corresponding Shopify order remains pending instead of canceling (#6021 #6027) @Kukks
|
||||
|
||||
### 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
|
||||
|
@ -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/*
|
||||
|
@ -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.
|
||||
|
Reference in New Issue
Block a user