Compare commits

..

30 Commits

Author SHA1 Message Date
Nicolas Dorier
413a9b4269 Add translation for store rate and wallet setup (#6271) 2024-10-03 19:21:19 +09:00
nicolas.dorier
a698aa8a5b Do not crash if payment method disabled when store supports it 2024-10-03 19:21:01 +09:00
nicolas.dorier
0f79526566 Do not make the test framework depends on CurrentDirectory 2024-10-03 16:04:16 +09:00
Nicolas Dorier
1ffbab7338 Small improvements to make development of plugins easier (#6270) 2024-10-03 15:16:21 +09:00
Chukwuleta Tobechi
3a71c45a89 Add updated image upload support on Crowdfund plugin (#6254)
* Add updated image upload support on Crowdfund plugin

* Refactor crowdfund image upload fix

* update crowdfund url for greenfield api

* Resolve integration test assertion

* Remove superfluous and unused command argument

* Fix missing validation error

* Minor API controller update

* Property and usage fixes

* Fix test after merge

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2024-10-03 10:39:41 +09:00
nicolas.dorier
8f062f918b Add comments 2024-10-03 10:35:47 +09:00
d11n
2f05d00219 V2 compatibility: Re-add deprecated navigation methods (#6267)
Gives the new methods a new name and re-adds the old ones in order to not break plugins. Simple enough backwartds compatible change, makred the old methods as obsolete to make plugin developers aware that new methods are available.
2024-10-02 08:25:43 +09:00
d11n
b48ca92675 Fix app stats sorting (#6265) 2024-10-02 08:23:25 +09:00
Nicolas Dorier
4a31cf0a09 Migrate payment requests (#6260) 2024-10-01 16:07:51 +09:00
d11n
82620ee327 Move wallet payment settings back to store settings (#6251)
Intermediate solution, until we implement these settings on the payment method level. Closes #6237.
2024-09-30 19:13:51 +09:00
nicolas.dorier
6d284b4124 Give time for pollers to detect payments after server restart 2024-09-27 15:48:16 +09:00
Chukwuleta Tobechi
83fa8cbf0f prevent app creation without wallet creation (#6255)
* prevent app creation without wallet creation

* resolve test failures

* resolve selenium test
2024-09-27 15:28:55 +09:00
Nicolas Dorier
9ba4b030ed Fix: Do not expose xpub without modify store permission (#6212) 2024-09-27 15:27:04 +09:00
d11n
272cc3d3c9 POS: Option for user sign in via the QR code (#6231)
* Login Code: Turn into Blazor component and extend with data for the app

* POS: Add login code for POS frontend

* Improve components, fix test
2024-09-26 19:10:14 +09:00
nicolas.dorier
b5590a38fe Add better error message if v1 routes are used. 2024-09-26 19:09:27 +09:00
d11n
443a350bad App Service: Validate IDs when parsing items template (#6228)
Validates missing and duplicate IDs on the edit actions and when creating/updating apps via the API.
Fails gracefully by excluding existing items without ID or with duplicate ID for the rest of the cases.

Fixes #6227.
2024-09-26 15:52:16 +09:00
nicolas.dorier
7013e618de Remove dead fields from swagger 2024-09-26 12:23:41 +09:00
Nicolas Dorier
363b60385b Renaming various properties in the Payouts API (#6246)
* Rename Payouts Currency/OriginalCurrency

* Rename Payout Processor PayoutMethodIds

* Rename paymentMethods to payoutMethodIds

* Rename payoutMethodIds to payoutMethods
2024-09-26 11:25:45 +09:00
nicolas.dorier
90635ffc4e Remove BTCPAY_EXPERIMENTALV2_CONFIRM 2024-09-25 23:11:53 +09:00
Nicolas Dorier
056f850268 Optimize load time of StoreRoles related pages/routes (#6245) 2024-09-25 23:10:13 +09:00
nicolas.dorier
336f2d88e9 Fix flaky CanManageWallet
Clicking on Sign Transaction in the Wallet Send page, will, when a hot
wallet is setup, use PostRedirect page to redirect to the
broadcast screen. The problem was that sometimes, s.Driver.PageSource
would return this PostRedirect page rather than the broadcast page.
Waiting for an element of the broadcast page fixes this issue.
2024-09-25 21:53:15 +09:00
nicolas.dorier
e16b4062b5 Fix payout processor migration 2024-09-25 18:50:49 +09:00
Nicolas Dorier
747dacf3b1 Consolidate migrations from alpha (#6244) 2024-09-25 18:23:10 +09:00
nicolas.dorier
f00a71922f Optimize queries from payout processor at startup 2024-09-24 23:39:05 +09:00
nicolas.dorier
c97c9d4ece Add SQL test for GetMonitoredInvoices 2024-09-24 22:07:02 +09:00
nicolas.dorier
9d3f8672d9 Fix GetMonitoredInvoices 2024-09-24 17:21:36 +09:00
Vincent Bouzon
fe48cd4236 fix InvoiceRepository.GetMonitoredInvoices (#6243) 2024-09-24 15:44:51 +09:00
nicolas.dorier
587d3aa612 Fix query 2024-09-24 09:52:28 +09:00
nicolas.dorier
8a951940fd Remove dead property 2024-09-24 09:47:46 +09:00
nicolas.dorier
b726ef8a2e Migrate PayoutProcessors's PayoutMethodId in entity migration 2024-09-24 09:43:02 +09:00
116 changed files with 1925 additions and 1204 deletions

View File

@@ -2,6 +2,7 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using BTCPayServer.Abstractions.Models;
namespace BTCPayServer.Abstractions.Contracts;
@@ -14,4 +15,5 @@ public interface IFileService
Task<string?> GetTemporaryFileUrl(Uri baseUri, string fileId, DateTimeOffset expiry,
bool isDownload);
Task RemoveFile(string fileId, string userId);
Task<UploadImageResultModel> UploadImage(IFormFile file, string userId, long maxFileSizeInBytes = 1_000_000);
}

View File

@@ -55,7 +55,7 @@ namespace BTCPayServer.Abstractions.Extensions
viewData[ACTIVE_CATEGORY_KEY] = activeCategory;
}
public static bool IsActiveCategory(this ViewDataDictionary viewData, string category, object id = null)
public static bool IsCategoryActive(this ViewDataDictionary viewData, string category, object id = null)
{
if (!viewData.ContainsKey(ACTIVE_CATEGORY_KEY)) return false;
var activeId = viewData[ACTIVE_ID_KEY];
@@ -65,12 +65,12 @@ namespace BTCPayServer.Abstractions.Extensions
return categoryMatch && idMatch;
}
public static bool IsActiveCategory<T>(this ViewDataDictionary viewData, T category, object id = null)
public static bool IsCategoryActive<T>(this ViewDataDictionary viewData, T category, object id = null)
{
return IsActiveCategory(viewData, category.ToString(), id);
return IsCategoryActive(viewData, category.ToString(), id);
}
public static bool IsActivePage(this ViewDataDictionary viewData, string page, string category, object id = null)
public static bool IsPageActive(this ViewDataDictionary viewData, string page, string category, object id = null)
{
if (!viewData.ContainsKey(ACTIVE_PAGE_KEY)) return false;
var activeId = viewData[ACTIVE_ID_KEY];
@@ -82,7 +82,7 @@ namespace BTCPayServer.Abstractions.Extensions
return categoryAndPageMatch && idMatch;
}
public static bool IsActivePage<T>(this ViewDataDictionary viewData, IEnumerable<T> pages, object id = null)
public static bool IsPageActive<T>(this ViewDataDictionary viewData, IEnumerable<T> pages, object id = null)
where T : IConvertible
{
return pages.Any(page => ActivePageClass(viewData, page.ToString(), page.GetType().ToString(), id) == ACTIVE_CLASS);
@@ -95,7 +95,7 @@ namespace BTCPayServer.Abstractions.Extensions
public static string ActiveCategoryClass(this ViewDataDictionary viewData, string category, object id = null)
{
return IsActiveCategory(viewData, category, id) ? ACTIVE_CLASS : null;
return IsCategoryActive(viewData, category, id) ? ACTIVE_CLASS : null;
}
public static string ActivePageClass<T>(this ViewDataDictionary viewData, T page, object id = null)
@@ -106,12 +106,42 @@ namespace BTCPayServer.Abstractions.Extensions
public static string ActivePageClass(this ViewDataDictionary viewData, string page, string category, object id = null)
{
return IsActivePage(viewData, page, category, id) ? ACTIVE_CLASS : null;
return IsPageActive(viewData, page, category, id) ? ACTIVE_CLASS : null;
}
public static string ActivePageClass<T>(this ViewDataDictionary viewData, IEnumerable<T> pages, object id = null) where T : IConvertible
{
return IsActivePage(viewData, pages, id) ? ACTIVE_CLASS : null;
return IsPageActive(viewData, pages, id) ? ACTIVE_CLASS : null;
}
[Obsolete("Use ActiveCategoryClass instead")]
public static string IsActiveCategory<T>(this ViewDataDictionary viewData, T category, object id = null)
{
return ActiveCategoryClass(viewData, category, id);
}
[Obsolete("Use ActiveCategoryClass instead")]
public static string IsActiveCategory(this ViewDataDictionary viewData, string category, object id = null)
{
return ActiveCategoryClass(viewData, category, id);
}
[Obsolete("Use ActivePageClass instead")]
public static string IsActivePage<T>(this ViewDataDictionary viewData, T page, object id = null) where T : IConvertible
{
return ActivePageClass(viewData, page, id);
}
[Obsolete("Use ActivePageClass instead")]
public static string IsActivePage<T>(this ViewDataDictionary viewData, IEnumerable<T> pages, object id = null) where T : IConvertible
{
return ActivePageClass(viewData, pages, id);
}
[Obsolete("Use ActivePageClass instead")]
public static string IsActivePage(this ViewDataDictionary viewData, string page, string category, object id = null)
{
return ActivePageClass(viewData, page, category, id);
}
public static HtmlString ToBrowserDate(this DateTimeOffset date, string netFormat, string jsDateFormat = "short", string jsTimeFormat = "short")

View File

@@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
namespace BTCPayServer.Abstractions.Models;
public class UploadImageResultModel
{
public bool Success { get; set; }
public string Response { get; set; } = string.Empty;
public IStoredFile? StoredFile { get; set; }
}

View File

@@ -46,9 +46,15 @@ public partial class BTCPayServerClient
return await SendHttpRequest<InvoiceData>($"api/v1/stores/{storeId}/invoices/{invoiceId}", null, HttpMethod.Get, token);
}
public virtual async Task<InvoicePaymentMethodDataModel[]> GetInvoicePaymentMethods(string storeId, string invoiceId,
bool onlyAccountedPayments = true, bool includeSensitive = false,
CancellationToken token = default)
{
return await SendHttpRequest<InvoicePaymentMethodDataModel[]>($"api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods", null, HttpMethod.Get, token);
var queryPayload = new Dictionary<string, object>
{
{ nameof(onlyAccountedPayments), onlyAccountedPayments },
{ nameof(includeSensitive), includeSensitive }
};
return await SendHttpRequest<InvoicePaymentMethodDataModel[]>($"api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods", queryPayload, HttpMethod.Get, token);
}
public virtual async Task ArchiveInvoice(string storeId, string invoiceId,

View File

@@ -19,7 +19,7 @@ namespace BTCPayServer.Client.Models
public DateTimeOffset? ExpiresAt { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? StartsAt { get; set; }
public string[] PaymentMethods { get; set; }
public string[] PayoutMethods { get; set; }
public bool AutoApproveClaims { get; set; }
}
}

View File

@@ -22,11 +22,12 @@ namespace BTCPayServer.Client.Models
public string PullPaymentId { get; set; }
public string Destination { get; set; }
public string PayoutMethodId { get; set; }
public string CryptoCode { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Amount { get; set; }
public decimal OriginalAmount { get; set; }
public string OriginalCurrency { get; set; }
public string PayoutCurrency { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal? PaymentMethodAmount { get; set; }
public decimal? PayoutAmount { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public PayoutState State { get; set; }
public int Revision { get; set; }

View File

@@ -4,6 +4,6 @@ namespace BTCPayServer.Client.Models
{
public string Name { get; set; }
public string FriendlyName { get; set; }
public string[] PaymentMethods { get; set; }
public string[] PayoutMethods { get; set; }
}
}

View File

@@ -22,7 +22,5 @@
<None Remove="DBScripts\002.RefactorPayouts.sql" />
<None Remove="DBScripts\003.RefactorPendingInvoicesPayments.sql" />
<None Remove="DBScripts\004.MonitoredInvoices.sql" />
<None Remove="DBScripts\005.PaymentsRenaming.sql" />
<None Remove="DBScripts\006.PaymentsRenaming.sql" />
</ItemGroup>
</Project>

View File

@@ -4,19 +4,20 @@ RETURNS JSONB AS $$
$$ LANGUAGE sql IMMUTABLE;
CREATE OR REPLACE FUNCTION get_monitored_invoices(payment_method_id TEXT)
RETURNS TABLE (invoice_id TEXT, payment_id TEXT) AS $$
CREATE OR REPLACE FUNCTION get_monitored_invoices(arg_payment_method_id TEXT, include_non_activated BOOLEAN)
RETURNS TABLE (invoice_id TEXT, payment_id TEXT, payment_method_id TEXT) AS $$
WITH cte AS (
-- Get all the invoices which are pending. Even if no payments.
SELECT i."Id" invoice_id, p."Id" payment_id FROM "Invoices" i LEFT JOIN "Payments" p ON i."Id" = p."InvoiceDataId"
SELECT i."Id" invoice_id, p."Id" payment_id, p."PaymentMethodId" payment_method_id FROM "Invoices" i LEFT JOIN "Payments" p ON i."Id" = p."InvoiceDataId"
WHERE is_pending(i."Status")
UNION ALL
-- For invoices not pending, take all of those which have pending payments
SELECT i."Id", p."Id" FROM "Invoices" i INNER JOIN "Payments" p ON i."Id" = p."InvoiceDataId"
SELECT i."Id" invoice_id, p."Id" payment_id, p."PaymentMethodId" payment_method_id FROM "Invoices" i INNER JOIN "Payments" p ON i."Id" = p."InvoiceDataId"
WHERE is_pending(p."Status") AND NOT is_pending(i."Status"))
SELECT cte.* FROM cte
LEFT JOIN "Payments" p ON cte.payment_id=p."Id"
LEFT JOIN "Invoices" i ON cte.invoice_id=i."Id"
WHERE (p."Type" IS NOT NULL AND p."Type" = payment_method_id) OR
(p."Type" IS NULL AND get_prompt(i."Blob2", payment_method_id) IS NOT NULL AND (get_prompt(i."Blob2", payment_method_id)->'activated')::BOOLEAN IS NOT FALSE);
JOIN "Invoices" i ON cte.invoice_id=i."Id"
LEFT JOIN "Payments" p ON cte.payment_id=p."Id" AND cte.payment_method_id=p."PaymentMethodId"
WHERE (p."PaymentMethodId" IS NOT NULL AND p."PaymentMethodId" = arg_payment_method_id) OR
(p."PaymentMethodId" IS NULL AND get_prompt(i."Blob2", arg_payment_method_id) IS NOT NULL AND
(include_non_activated IS TRUE OR (get_prompt(i."Blob2", arg_payment_method_id)->'inactive')::BOOLEAN IS NOT TRUE));
$$ LANGUAGE SQL STABLE;

View File

@@ -1,17 +0,0 @@
DROP FUNCTION get_monitored_invoices;
CREATE OR REPLACE FUNCTION get_monitored_invoices(payment_method_id TEXT)
RETURNS TABLE (invoice_id TEXT, payment_id TEXT, payment_method_id TEXT) AS $$
WITH cte AS (
-- Get all the invoices which are pending. Even if no payments.
SELECT i."Id" invoice_id, p."Id" payment_id, p."PaymentMethodId" payment_method_id FROM "Invoices" i LEFT JOIN "Payments" p ON i."Id" = p."InvoiceDataId"
WHERE is_pending(i."Status")
UNION ALL
-- For invoices not pending, take all of those which have pending payments
SELECT i."Id", p."Id", p."PaymentMethodId" payment_method_id FROM "Invoices" i INNER JOIN "Payments" p ON i."Id" = p."InvoiceDataId"
WHERE is_pending(p."Status") AND NOT is_pending(i."Status"))
SELECT cte.* FROM cte
LEFT JOIN "Payments" p ON cte.payment_id=p."Id" AND cte.payment_id=p."PaymentMethodId"
LEFT JOIN "Invoices" i ON cte.invoice_id=i."Id"
WHERE (p."PaymentMethodId" IS NOT NULL AND p."PaymentMethodId" = payment_method_id) OR
(p."PaymentMethodId" IS NULL AND get_prompt(i."Blob2", payment_method_id) IS NOT NULL AND (get_prompt(i."Blob2", payment_method_id)->'activated')::BOOLEAN IS NOT FALSE);
$$ LANGUAGE SQL STABLE;

View File

@@ -1,18 +0,0 @@
DROP FUNCTION get_monitored_invoices;
CREATE OR REPLACE FUNCTION get_monitored_invoices(payment_method_id TEXT, include_non_activated BOOLEAN)
RETURNS TABLE (invoice_id TEXT, payment_id TEXT, payment_method_id TEXT) AS $$
WITH cte AS (
-- Get all the invoices which are pending. Even if no payments.
SELECT i."Id" invoice_id, p."Id" payment_id, p."PaymentMethodId" payment_method_id FROM "Invoices" i LEFT JOIN "Payments" p ON i."Id" = p."InvoiceDataId"
WHERE is_pending(i."Status")
UNION ALL
-- For invoices not pending, take all of those which have pending payments
SELECT i."Id", p."Id", p."PaymentMethodId" payment_method_id FROM "Invoices" i INNER JOIN "Payments" p ON i."Id" = p."InvoiceDataId"
WHERE is_pending(p."Status") AND NOT is_pending(i."Status"))
SELECT cte.* FROM cte
LEFT JOIN "Payments" p ON cte.payment_id=p."Id" AND cte.payment_id=p."PaymentMethodId"
LEFT JOIN "Invoices" i ON cte.invoice_id=i."Id"
WHERE (p."PaymentMethodId" IS NOT NULL AND p."PaymentMethodId" = payment_method_id) OR
(p."PaymentMethodId" IS NULL AND get_prompt(i."Blob2", payment_method_id) IS NOT NULL AND
(include_non_activated IS TRUE OR (get_prompt(i."Blob2", payment_method_id)->'activated')::BOOLEAN IS NOT FALSE));
$$ LANGUAGE SQL STABLE;

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Reflection.Metadata;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data
{
public partial class PaymentRequestData : MigrationInterceptor.IHasMigration
{
[NotMapped]
public bool Migrated { get; set; }
public bool TryMigrate()
{
#pragma warning disable CS0618 // Type or member is obsolete
if (Blob is null && Blob2 is not null)
return false;
if (Blob2 is null)
{
Blob2 = Blob is not (null or { Length: 0 }) ? MigrationExtensions.Unzip(Blob) : "{}";
Blob2 = MigrationExtensions.SanitizeJSON(Blob2);
}
Blob = null;
#pragma warning restore CS0618 // Type or member is obsolete
var jobj = JObject.Parse(Blob2);
// Fixup some legacy payment requests
if (jobj["expiryDate"].Type == JTokenType.Date)
{
jobj["expiryDate"] = new JValue(NBitcoin.Utils.DateTimeToUnixTime(jobj["expiryDate"].Value<DateTime>()));
Blob2 = jobj.ToString(Newtonsoft.Json.Formatting.None);
}
return true;
}
}
}

View File

@@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data
{
public class PaymentRequestData : IHasBlobUntyped
public partial class PaymentRequestData : IHasBlobUntyped
{
public string Id { get; set; }
public DateTimeOffset Created { get; set; }

View File

@@ -11,5 +11,30 @@ namespace BTCPayServer.Migrations
[DBScript("002.RefactorPayouts.sql")]
public partial class migratepayouts : DBScriptsMigration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
base.Up(migrationBuilder);
migrationBuilder.RenameColumn(
name: "Destination",
table: "Payouts",
newName: "DedupId");
migrationBuilder.RenameIndex(
name: "IX_Payouts_Destination_State",
table: "Payouts",
newName: "IX_Payouts_DedupId_State");
migrationBuilder.RenameColumn(
name: "PaymentMethod",
table: "PayoutProcessors",
newName: "PayoutMethodId");
migrationBuilder.Sql("""
UPDATE "PayoutProcessors"
SET
"PayoutMethodId" = CASE WHEN STRPOS("PayoutMethodId", '_') = 0 THEN "PayoutMethodId" || '-CHAIN'
WHEN STRPOS("PayoutMethodId", '_LightningLike') > 0 THEN split_part("PayoutMethodId", '_LightningLike', 1) || '-LN'
WHEN STRPOS("PayoutMethodId", '_LNURLPAY') > 0 THEN split_part("PayoutMethodId",'_LNURLPAY', 1) || '-LN'
ELSE "PayoutMethodId" END
""");
}
}
}

View File

@@ -1,36 +0,0 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240906010127_renamecol")]
public partial class renamecol : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "Destination",
table: "Payouts",
newName: "DedupId");
migrationBuilder.RenameIndex(
name: "IX_Payouts_Destination_State",
table: "Payouts",
newName: "IX_Payouts_DedupId_State");
migrationBuilder.RenameColumn(
name: "PaymentMethod",
table: "PayoutProcessors",
newName: "PayoutMethodId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -9,7 +9,6 @@ namespace BTCPayServer.Migrations
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240923065254_refactorpayments")]
[DBScript("005.PaymentsRenaming.sql")]
public partial class refactorpayments : DBScriptsMigration
{
/// <inheritdoc />

View File

@@ -1,31 +0,0 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240923071444_temprefactor2")]
public partial class temprefactor2 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropPrimaryKey(
name: "PK_AddressInvoices",
table: "AddressInvoices");
migrationBuilder.AddPrimaryKey(
name: "PK_AddressInvoices",
table: "AddressInvoices",
columns: new[] { "Address", "PaymentMethodId" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240919034505_monitoredinvoices")]
[Migration("20240924065254_monitoredinvoices")]
[DBScript("004.MonitoredInvoices.sql")]
public partial class monitoredinvoices : DBScriptsMigration
{

View File

@@ -1,15 +0,0 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240924071444_temprefactor3")]
[DBScript("006.PaymentsRenaming.sql")]
public partial class temprefactor3 : DBScriptsMigration
{
}
}

View File

@@ -7,6 +7,15 @@
<!--https://devblogs.microsoft.com/aspnet/testing-asp-net-core-mvc-web-apps-in-memory/-->
<PreserveCompilationContext>true</PreserveCompilationContext>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="BTCPayServer.Tests.OutputPathAttribute">
<!-- _Parameter1, _Parameter2, etc. correspond to the
matching parameter of a constructor of that .NET attribute type -->
<_Parameter1>$([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', '$(OutputPath)'))</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<!--https://devblogs.microsoft.com/aspnet/testing-asp-net-core-mvc-web-apps-in-memory/-->
<Target Name="CopyAditionalFiles" AfterTargets="Build" Condition="'$(TargetFramework)'!=''">
<ItemGroup>
@@ -61,4 +70,7 @@
<ProjectReference Include="..\BTCPayServer\BTCPayServer.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="obj\Debug\net8.0\" />
</ItemGroup>
</Project>

View File

@@ -162,6 +162,8 @@ namespace BTCPayServer.Tests
HttpClient.BaseAddress = ServerUri;
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development");
var confBuilder = new DefaultConfiguration() { Logger = LoggerProvider.CreateLogger("Console") }.CreateConfigurationBuilder(new[] { "--datadir", _Directory, "--conf", confPath, "--disable-registration", DisableRegistration ? "true" : "false" });
// This make sure that tests work outside of this assembly (ie, test project it a plugin)
confBuilder.SetBasePath(TestUtils.TestDirectory);
#if DEBUG
confBuilder.AddJsonFile("appsettings.dev.json", true, false);
#endif
@@ -265,7 +267,7 @@ namespace BTCPayServer.Tests
private string FindBTCPayServerDirectory()
{
var solutionDirectory = TestUtils.TryGetSolutionDirectoryInfo(Directory.GetCurrentDirectory());
var solutionDirectory = TestUtils.TryGetSolutionDirectoryInfo();
return Path.Combine(solutionDirectory.FullName, "BTCPayServer");
}

View File

@@ -39,6 +39,8 @@ namespace BTCPayServer.Tests
await user.GrantAccessAsync();
var user2 = tester.NewAccount();
await user2.GrantAccessAsync();
await user.RegisterDerivationSchemeAsync("BTC");
await user2.RegisterDerivationSchemeAsync("BTC");
var apps = user.GetController<UIAppsController>();
var apps2 = user2.GetController<UIAppsController>();
var crowdfund = user.GetController<UICrowdfundController>();
@@ -79,7 +81,7 @@ namespace BTCPayServer.Tests
Assert.False(app.Archived);
var crowdfundViewModel = await crowdfund.UpdateCrowdfund(app.Id).AssertViewModelAsync<UpdateCrowdfundViewModel>();
crowdfundViewModel.Enabled = true;
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel, "save").Result);
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel).Result);
Assert.IsType<ViewResult>(await crowdfund.ViewCrowdfund(app.Id));
// Delete
Assert.IsType<NotFoundResult>(apps2.DeleteApp(app.Id));
@@ -119,7 +121,7 @@ namespace BTCPayServer.Tests
crowdfundViewModel.Enabled = false;
crowdfundViewModel.EndDate = null;
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel, "save").Result);
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel).Result);
var anonAppPubsController = tester.PayTester.GetController<UICrowdfundController>();
var crowdfundController = user.GetController<UICrowdfundController>();
@@ -144,7 +146,7 @@ namespace BTCPayServer.Tests
crowdfundViewModel.StartDate = DateTime.Today.AddDays(2);
crowdfundViewModel.Enabled = true;
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel, "save").Result);
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel).Result);
Assert.IsType<NotFoundObjectResult>(await anonAppPubsController.ContributeToCrowdfund(app.Id, new ContributeToCrowdfund()
{
Amount = new decimal(0.01)
@@ -155,7 +157,7 @@ namespace BTCPayServer.Tests
crowdfundViewModel.EndDate = DateTime.Today.AddDays(-1);
crowdfundViewModel.Enabled = true;
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel, "save").Result);
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel).Result);
Assert.IsType<NotFoundObjectResult>(await anonAppPubsController.ContributeToCrowdfund(app.Id, new ContributeToCrowdfund()
{
Amount = new decimal(0.01)
@@ -168,7 +170,7 @@ namespace BTCPayServer.Tests
crowdfundViewModel.TargetAmount = 1;
crowdfundViewModel.TargetCurrency = "BTC";
crowdfundViewModel.EnforceTargetAmount = true;
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel, "save").Result);
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel).Result);
Assert.IsType<NotFoundObjectResult>(await anonAppPubsController.ContributeToCrowdfund(app.Id, new ContributeToCrowdfund()
{
Amount = new decimal(1.01)
@@ -212,7 +214,7 @@ namespace BTCPayServer.Tests
crowdfundViewModel.TargetCurrency = "BTC";
crowdfundViewModel.UseAllStoreInvoices = true;
crowdfundViewModel.EnforceTargetAmount = true;
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel, "save").Result);
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel).Result);
var publicApps = user.GetController<UICrowdfundController>();
@@ -266,7 +268,7 @@ namespace BTCPayServer.Tests
Assert.Contains(AppService.GetAppInternalTag(app.Id), invoiceEntity.InternalTags);
crowdfundViewModel.UseAllStoreInvoices = false;
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel, "save").Result);
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel).Result);
TestLogs.LogInformation("Because UseAllStoreInvoices is false, let's make sure the invoice is not tagged");
invoice = await user.BitPay.CreateInvoiceAsync(new Invoice
@@ -285,7 +287,7 @@ namespace BTCPayServer.Tests
TestLogs.LogInformation("After turning setting a softcap, let's check that only actual payments are counted");
crowdfundViewModel.EnforceTargetAmount = false;
crowdfundViewModel.UseAllStoreInvoices = true;
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel, "save").Result);
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel).Result);
invoice = await user.BitPay.CreateInvoiceAsync(new Invoice
{
Buyer = new Buyer { email = "test@fwf.com" },
@@ -354,7 +356,7 @@ namespace BTCPayServer.Tests
crowdfundViewModel.FormId = lstForms[0].Id;
crowdfundViewModel.TargetCurrency = "BTC";
crowdfundViewModel.Enabled = true;
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel, "save").Result);
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel).Result);
var vm2 = await crowdfund.CrowdfundForm(app.Id, (decimal?)0.01).AssertViewModelAsync<FormViewModel>();
var res = await crowdfund.CrowdfundFormSubmit(app.Id, (decimal)0.01, "", vm2);
@@ -409,7 +411,7 @@ namespace BTCPayServer.Tests
crowdfundViewModel.TargetCurrency = "BTC";
crowdfundViewModel.Enabled = true;
crowdfundViewModel.PerksTemplate = "[{\"id\": \"xxx\",\"title\": \"Perk 1\",\"priceType\": \"Fixed\",\"price\": \"0.001\",\"image\": \"\",\"description\": \"\",\"categories\": [],\"disabled\": false}]";
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel, "save").Result);
Assert.IsType<RedirectToActionResult>(crowdfund.UpdateCrowdfund(app.Id, crowdfundViewModel).Result);
var vm2 = await crowdfund.CrowdfundForm(app.Id, (decimal?)0.01, "xxx").AssertViewModelAsync<FormViewModel>();
var res = await crowdfund.CrowdfundFormSubmit(app.Id, (decimal)0.01, "xxx", vm2);

View File

@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Data;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Tests.Logging;
using Dapper;
using Microsoft.EntityFrameworkCore;
@@ -42,6 +43,13 @@ namespace BTCPayServer.Tests
}), _loggerFactory);
}
public InvoiceRepository GetInvoiceRepository()
{
var logs = new BTCPayServer.Logging.Logs();
logs.Configure(_loggerFactory);
return new InvoiceRepository(CreateContextFactory(), new EventAggregator(logs));
}
public ApplicationDbContext CreateContext() => CreateContextFactory().CreateContext();
public async Task MigrateAsync()
@@ -59,18 +67,21 @@ namespace BTCPayServer.Tests
await conn.ExecuteAsync($"CREATE DATABASE \"{dbname}\";");
}
public async Task MigrateUntil(string migration)
public async Task MigrateUntil(string migration = null)
{
using var ctx = CreateContext();
var db = ctx.Database.GetDbConnection();
await EnsureCreatedAsync();
var migrations = ctx.Database.GetMigrations().ToArray();
var untilMigrationIdx = Array.IndexOf(migrations, migration);
if (untilMigrationIdx == -1)
throw new InvalidOperationException($"Migration {migration} not found");
notAppliedMigrations = migrations[untilMigrationIdx..];
await db.ExecuteAsync("CREATE TABLE IF NOT EXISTS \"__EFMigrationsHistory\" (\"MigrationId\" TEXT, \"ProductVersion\" TEXT)");
await db.ExecuteAsync("INSERT INTO \"__EFMigrationsHistory\" VALUES (@migration, '8.0.0')", notAppliedMigrations.Select(m => new { migration = m }).ToArray());
if (migration is not null)
{
var untilMigrationIdx = Array.IndexOf(migrations, migration);
if (untilMigrationIdx == -1)
throw new InvalidOperationException($"Migration {migration} not found");
notAppliedMigrations = migrations[untilMigrationIdx..];
await db.ExecuteAsync("CREATE TABLE IF NOT EXISTS \"__EFMigrationsHistory\" (\"MigrationId\" TEXT, \"ProductVersion\" TEXT)");
await db.ExecuteAsync("INSERT INTO \"__EFMigrationsHistory\" VALUES (@migration, '8.0.0')", notAppliedMigrations.Select(m => new { migration = m }).ToArray());
}
await ctx.Database.MigrateAsync();
}

View File

@@ -3,7 +3,9 @@ using System.Threading.Tasks;
using BTCPayServer.Payments;
using Dapper;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.Altcoins;
using NBitpayClient;
using Newtonsoft.Json.Linq;
using Xunit;
using Xunit.Abstractions;
@@ -18,6 +20,110 @@ namespace BTCPayServer.Tests
{
}
[Fact]
public async Task CanQueryMonitoredInvoices()
{
var tester = CreateDBTester();
await tester.MigrateUntil();
var invoiceRepository = tester.GetInvoiceRepository();
using var ctx = tester.CreateContext();
var conn = ctx.Database.GetDbConnection();
async Task AddPrompt(string invoiceId, string paymentMethodId, bool activated = true)
{
JObject prompt = new JObject();
if (!activated)
prompt["inactive"] = true;
prompt["currency"] = "USD";
var query = """
UPDATE "Invoices" SET "Blob2" = jsonb_set('{"prompts": {}}'::JSONB || COALESCE("Blob2",'{}'), ARRAY['prompts','@paymentMethodId'], '@prompt'::JSONB)
WHERE "Id" = '@invoiceId'
""";
query = query.Replace("@paymentMethodId", paymentMethodId);
query = query.Replace("@prompt", prompt.ToString());
query = query.Replace("@invoiceId", invoiceId);
Assert.Equal(1, await conn.ExecuteAsync(query));
}
await conn.ExecuteAsync("""
INSERT INTO "Invoices" ("Id", "Created", "Status","Currency") VALUES
('BTCOnly', NOW(), 'New', 'USD'),
('LTCOnly', NOW(), 'New', 'USD'),
('LTCAndBTC', NOW(), 'New', 'USD'),
('LTCAndBTCLazy', NOW(), 'New', 'USD')
""");
foreach (var invoiceId in new string[] { "LTCOnly", "LTCAndBTCLazy", "LTCAndBTC" })
{
await AddPrompt(invoiceId, "LTC-CHAIN", true);
}
foreach (var invoiceId in new string[] { "BTCOnly", "LTCAndBTC" })
{
await AddPrompt(invoiceId, "BTC-CHAIN", true);
}
await AddPrompt("LTCAndBTCLazy", "BTC-CHAIN", false);
var btc = PaymentMethodId.Parse("BTC-CHAIN");
var ltc = PaymentMethodId.Parse("LTC-CHAIN");
var invoices = await invoiceRepository.GetMonitoredInvoices(btc);
Assert.Equal(2, invoices.Length);
foreach (var invoiceId in new[] { "BTCOnly", "LTCAndBTC" })
{
Assert.Contains(invoices, i => i.Id == invoiceId);
}
invoices = await invoiceRepository.GetMonitoredInvoices(btc, true);
Assert.Equal(3, invoices.Length);
foreach (var invoiceId in new[] { "BTCOnly", "LTCAndBTC", "LTCAndBTCLazy" })
{
Assert.Contains(invoices, i => i.Id == invoiceId);
}
invoices = await invoiceRepository.GetMonitoredInvoices(ltc);
Assert.Equal(3, invoices.Length);
foreach (var invoiceId in new[] { "LTCAndBTC", "LTCAndBTC", "LTCAndBTCLazy" })
{
Assert.Contains(invoices, i => i.Id == invoiceId);
}
await conn.ExecuteAsync("""
INSERT INTO "Payments" ("Id", "InvoiceDataId", "PaymentMethodId", "Status", "Blob2", "Created", "Amount", "Currency") VALUES
('1','LTCAndBTC', 'LTC-CHAIN', 'Processing', '{}'::JSONB, NOW(), 123, 'USD'),
('2','LTCAndBTC', 'BTC-CHAIN', 'Processing', '{}'::JSONB, NOW(), 123, 'USD'),
('3','LTCAndBTC', 'BTC-CHAIN', 'Processing', '{}'::JSONB, NOW(), 123, 'USD'),
('4','LTCAndBTC', 'BTC-CHAIN', 'Settled', '{}'::JSONB, NOW(), 123, 'USD');
INSERT INTO "AddressInvoices" ("InvoiceDataId", "Address", "PaymentMethodId") VALUES
('LTCAndBTC', 'BTC1', 'BTC-CHAIN'),
('LTCAndBTC', 'BTC2', 'BTC-CHAIN'),
('LTCAndBTC', 'LTC1', 'LTC-CHAIN');
""");
var invoice = Assert.Single(await invoiceRepository.GetMonitoredInvoices(ltc), i => i.Id == "LTCAndBTC");
var payment = Assert.Single(invoice.GetPayments(false));
Assert.Equal("1", payment.Id);
foreach (var includeNonActivated in new[] { true, false })
{
invoices = await invoiceRepository.GetMonitoredInvoices(btc, includeNonActivated);
invoice = Assert.Single(invoices, i => i.Id == "LTCAndBTC");
var payments = invoice.GetPayments(false);
Assert.Equal(3, payments.Count);
foreach (var paymentId in new[] { "2", "3", "4" })
{
Assert.Contains(payments, p => p.Id == paymentId);
}
Assert.Equal(2, invoice.Addresses.Count);
foreach (var addr in new[] { "BTC1", "BTC2" })
{
Assert.Contains(invoice.Addresses, p => p.Address == addr);
}
if (!includeNonActivated)
Assert.DoesNotContain(invoices, i => i.Id == "LTCAndBTCLazy");
else
Assert.Contains(invoices, i => i.Id == "LTCAndBTCLazy");
}
}
[Fact]
public async Task CanMigrateInvoiceAddresses()
{

View File

@@ -97,6 +97,9 @@ namespace BTCPayServer.Tests
Assert.NotNull(e.APIError.Message);
GreenfieldPermissionAPIError permissionError = Assert.IsType<GreenfieldPermissionAPIError>(e.APIError);
Assert.Equal(Policies.CanModifyStoreSettings, permissionError.MissingPermission);
var client = await user.CreateClient(Policies.CanViewStoreSettings);
await AssertAPIError("unsupported-in-v2", () => client.SendHttpRequest<object>($"api/v1/stores/{user.StoreId}/payment-methods/LightningNetwork"));
}
[Fact(Timeout = TestTimeout)]
@@ -368,6 +371,27 @@ namespace BTCPayServer.Tests
}
)
);
var template = @"[
{
""description"": ""Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years."",
""id"": ""green-tea"",
""image"": ""~/img/pos-sample/green-tea.jpg"",
""priceType"": ""Fixed"",
""price"": ""1"",
""title"": ""Green Tea"",
""disabled"": false
}
]";
await AssertValidationError(new[] { "Template" },
async () => await client.CreatePointOfSaleApp(
user.StoreId,
new PointOfSaleAppRequest
{
AppName = "good name",
Template = template.Replace(@"""id"": ""green-tea"",", "")
}
)
);
// Test creating a POS app successfully
var app = await client.CreatePointOfSaleApp(
@@ -376,7 +400,8 @@ namespace BTCPayServer.Tests
{
AppName = "test app from API",
Currency = "JPY",
Title = "test app title"
Title = "test app title",
Template = template
}
);
Assert.Equal("test app from API", app.AppName);
@@ -559,6 +584,27 @@ namespace BTCPayServer.Tests
}
)
);
var template = @"[
{
""description"": ""Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years."",
""id"": ""green-tea"",
""image"": ""~/img/pos-sample/green-tea.jpg"",
""priceType"": ""Fixed"",
""price"": ""1"",
""title"": ""Green Tea"",
""disabled"": false
}
]";
await AssertValidationError(new[] { "PerksTemplate" },
async () => await client.CreateCrowdfundApp(
user.StoreId,
new CrowdfundAppRequest
{
AppName = "good name",
PerksTemplate = template.Replace(@"""id"": ""green-tea"",", "")
}
)
);
// Test creating a crowdfund app
var app = await client.CreateCrowdfundApp(
@@ -566,7 +612,8 @@ namespace BTCPayServer.Tests
new CrowdfundAppRequest
{
AppName = "test app from API",
Title = "test app title"
Title = "test app title",
PerksTemplate = template
}
);
Assert.Equal("test app from API", app.AppName);
@@ -1104,7 +1151,7 @@ namespace BTCPayServer.Tests
Description = "Test description",
Amount = 12.3m,
Currency = "BTC",
PaymentMethods = new[] { "BTC" }
PayoutMethods = new[] { "BTC" }
});
void VerifyResult()
@@ -1135,7 +1182,7 @@ namespace BTCPayServer.Tests
Name = "Test 2",
Amount = 12.3m,
Currency = "BTC",
PaymentMethods = new[] { "BTC" },
PayoutMethods = new[] { "BTC" },
BOLT11Expiration = TimeSpan.FromDays(31.0)
});
Assert.Equal(TimeSpan.FromDays(31.0), test2.BOLT11Expiration);
@@ -1182,13 +1229,13 @@ namespace BTCPayServer.Tests
payouts = await unauthenticated.GetPayouts(pps[0].Id);
var payout2 = Assert.Single(payouts);
Assert.Equal(payout.Amount, payout2.Amount);
Assert.Equal(payout.OriginalAmount, payout2.OriginalAmount);
Assert.Equal(payout.Id, payout2.Id);
Assert.Equal(destination, payout2.Destination);
Assert.Equal(PayoutState.AwaitingApproval, payout.State);
Assert.Equal("BTC-CHAIN", payout2.PayoutMethodId);
Assert.Equal("BTC", payout2.CryptoCode);
Assert.Null(payout.PaymentMethodAmount);
Assert.Equal("BTC", payout2.PayoutCurrency);
Assert.Null(payout.PayoutAmount);
TestLogs.LogInformation("Can't overdraft");
@@ -1230,7 +1277,7 @@ namespace BTCPayServer.Tests
Amount = 12.3m,
StartsAt = start,
Currency = "BTC",
PaymentMethods = new[] { "BTC" }
PayoutMethods = new[] { "BTC" }
});
Assert.Equal(start, inFuture.StartsAt);
Assert.Null(inFuture.ExpiresAt);
@@ -1248,7 +1295,7 @@ namespace BTCPayServer.Tests
Amount = 12.3m,
ExpiresAt = expires,
Currency = "BTC",
PaymentMethods = new[] { "BTC" }
PayoutMethods = new[] { "BTC" }
});
await this.AssertAPIError("expired", async () => await unauthenticated.CreatePayout(inPast.Id, new CreatePayoutRequest()
{
@@ -1272,7 +1319,7 @@ namespace BTCPayServer.Tests
Name = "Test USD",
Amount = 5000m,
Currency = "USD",
PaymentMethods = new[] { "BTC" }
PayoutMethods = new[] { "BTC" }
});
await this.AssertAPIError("lnurl-not-supported", async () => await unauthenticated.GetPullPaymentLNURL(pp.Id));
@@ -1297,8 +1344,8 @@ namespace BTCPayServer.Tests
Revision = payout.Revision
});
Assert.Equal(PayoutState.AwaitingPayment, payout.State);
Assert.NotNull(payout.PaymentMethodAmount);
Assert.Equal(1.0m, payout.PaymentMethodAmount); // 1 BTC == 5000 USD in tests
Assert.NotNull(payout.PayoutAmount);
Assert.Equal(1.0m, payout.PayoutAmount); // 1 BTC == 5000 USD in tests
await this.AssertAPIError("invalid-state", async () => await client.ApprovePayout(storeId, payout.Id, new ApprovePayoutRequest()
{
Revision = payout.Revision
@@ -1310,7 +1357,7 @@ namespace BTCPayServer.Tests
Name = "Test 2",
Amount = 12.303228134m,
Currency = "BTC",
PaymentMethods = new[] { "BTC" }
PayoutMethods = new[] { "BTC" }
});
destination = (await tester.ExplorerNode.GetNewAddressAsync()).ToString();
payout = await unauthenticated.CreatePayout(test3.Id, new CreatePayoutRequest()
@@ -1320,8 +1367,8 @@ namespace BTCPayServer.Tests
});
payout = await client.ApprovePayout(storeId, payout.Id, new ApprovePayoutRequest());
// The payout should round the value of the payment down to the network of the payment method
Assert.Equal(12.30322814m, payout.PaymentMethodAmount);
Assert.Equal(12.303228134m, payout.Amount);
Assert.Equal(12.30322814m, payout.PayoutAmount);
Assert.Equal(12.303228134m, payout.OriginalAmount);
await client.MarkPayoutPaid(storeId, payout.Id);
payout = (await client.GetPayouts(payout.PullPaymentId)).First(data => data.Id == payout.Id);
@@ -1334,7 +1381,7 @@ namespace BTCPayServer.Tests
Name = "Test 3",
Amount = 12.303228134m,
Currency = "BTC",
PaymentMethods = new[] { "BTC", "BTC-LightningNetwork", "BTC_LightningLike" }
PayoutMethods = new[] { "BTC", "BTC-LightningNetwork", "BTC_LightningLike" }
});
var lnrURLs = await unauthenticated.GetPullPaymentLNURL(test4.Id);
Assert.IsType<string>(lnrURLs.LNURLBech32);
@@ -1409,7 +1456,7 @@ namespace BTCPayServer.Tests
Name = "Test SATS",
Amount = 21000,
Currency = "SATS",
PaymentMethods = new[] { "BTC", "BTC-LightningNetwork", "BTC_LightningLike" }
PayoutMethods = new[] { "BTC", "BTC-LightningNetwork", "BTC_LightningLike" }
});
lnrURLs = await unauthenticated.GetPullPaymentLNURL(testSats.Id);
Assert.IsType<string>(lnrURLs.LNURLBech32);
@@ -1427,7 +1474,7 @@ namespace BTCPayServer.Tests
Amount = 100,
Currency = "USD",
Name = "pull payment",
PaymentMethods = new[] { "BTC" },
PayoutMethods = new[] { "BTC" },
AutoApproveClaims = true
});
});
@@ -1447,7 +1494,7 @@ namespace BTCPayServer.Tests
Amount = 100,
Currency = "USD",
Name = "pull payment",
PaymentMethods = new[] { "BTC" },
PayoutMethods = new[] { "BTC" },
AutoApproveClaims = true
});
@@ -2374,6 +2421,14 @@ namespace BTCPayServer.Tests
invoice = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest { Amount = 5000.0m, Currency = "USD" });
methods = await client.GetInvoicePaymentMethods(user.StoreId, invoice.Id);
method = methods.First();
Assert.Equal(JTokenType.Null, method.AdditionalData["accountDerivation"].Type);
Assert.NotNull(method.AdditionalData["keyPath"]);
methods = await client.GetInvoicePaymentMethods(user.StoreId, invoice.Id, includeSensitive: true);
method = methods.First();
Assert.Equal(JTokenType.String, method.AdditionalData["accountDerivation"].Type);
var clientViewOnly = await user.CreateClient(Policies.CanViewInvoices);
await AssertApiError(403, "missing-permission", () => clientViewOnly.GetInvoicePaymentMethods(user.StoreId, invoice.Id, includeSensitive: true));
await tester.WaitForEvent<NewOnChainTransactionEvent>(async () =>
{
@@ -2717,8 +2772,8 @@ namespace BTCPayServer.Tests
var includeNonActivated = true;
Assert.Single(await invoiceRepo.GetMonitoredInvoices(PaymentMethodId.Parse("BTC-CHAIN"), includeNonActivated), i => i.Id == invoice.Id);
includeNonActivated = false;
Assert.Single(await invoiceRepo.GetMonitoredInvoices(PaymentMethodId.Parse("BTC-CHAIN"), includeNonActivated), i => i.Id == invoice.Id);
Assert.Single(await invoiceRepo.GetMonitoredInvoices(PaymentMethodId.Parse("BTC-CHAIN")), i => i.Id == invoice.Id);
Assert.DoesNotContain(await invoiceRepo.GetMonitoredInvoices(PaymentMethodId.Parse("BTC-CHAIN"), includeNonActivated), i => i.Id == invoice.Id);
Assert.DoesNotContain(await invoiceRepo.GetMonitoredInvoices(PaymentMethodId.Parse("BTC-CHAIN")), i => i.Id == invoice.Id);
//
paymentMethods = await client.GetInvoicePaymentMethods(store.Id, invoice.Id);
@@ -4122,7 +4177,12 @@ namespace BTCPayServer.Tests
var resp = await tester.CustomerLightningD.Pay(inv.BOLT11);
Assert.Equal(PayResult.Ok, resp.Result);
var store = tester.PayTester.GetService<StoreRepository>();
Assert.True(await store.InternalNodePayoutAuthorized(admin.StoreId));
Assert.False(await store.InternalNodePayoutAuthorized("blah"));
await admin.MakeAdmin(false);
Assert.False(await store.InternalNodePayoutAuthorized(admin.StoreId));
await admin.MakeAdmin(true);
var customerInvoice = await tester.CustomerLightningD.CreateInvoice(LightMoney.FromUnit(10, LightMoneyUnit.Satoshi),
Guid.NewGuid().ToString(), TimeSpan.FromDays(40));
@@ -4183,7 +4243,7 @@ namespace BTCPayServer.Tests
PayoutMethodId = "BTC_LightningNetwork",
Destination = customerInvoice.BOLT11
});
Assert.Equal(payout2.Amount, new Money(100, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC));
Assert.Equal(payout2.OriginalAmount, new Money(100, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC));
}
[Fact(Timeout = 60 * 2 * 1000)]
@@ -4227,7 +4287,7 @@ namespace BTCPayServer.Tests
Amount = 100,
Currency = "USD",
Name = "pull payment",
PaymentMethods = new[] { "BTC" }
PayoutMethods = new[] { "BTC" }
});
var notapprovedPayoutWithPullPayment = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest()
@@ -4253,7 +4313,7 @@ namespace BTCPayServer.Tests
Assert.Equal(3, payouts.Length);
Assert.Empty(payouts.Where(data => data.State == PayoutState.AwaitingApproval));
Assert.Empty(payouts.Where(data => data.PaymentMethodAmount is null));
Assert.Empty(payouts.Where(data => data.PayoutAmount is null));
Assert.Empty(await adminClient.ShowOnChainWalletTransactions(admin.StoreId, "BTC"));
@@ -4266,12 +4326,12 @@ namespace BTCPayServer.Tests
Assert.Equal(3600, Assert.Single(await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC")).IntervalSeconds.TotalSeconds);
var tpGen = Assert.Single(await adminClient.GetPayoutProcessors(admin.StoreId));
Assert.Equal("BTC-CHAIN", Assert.Single(tpGen.PaymentMethods));
Assert.Equal("BTC-CHAIN", Assert.Single(tpGen.PayoutMethods));
//still too poor to process any payouts
Assert.Empty(await adminClient.ShowOnChainWalletTransactions(admin.StoreId, "BTC"));
await adminClient.RemovePayoutProcessor(admin.StoreId, tpGen.Name, tpGen.PaymentMethods.First());
await adminClient.RemovePayoutProcessor(admin.StoreId, tpGen.Name, tpGen.PayoutMethods.First());
Assert.Empty(await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC"));
Assert.Empty(await adminClient.GetPayoutProcessors(admin.StoreId));

View File

@@ -0,0 +1,13 @@
using System;
namespace BTCPayServer.Tests
{
public class OutputPathAttribute : Attribute
{
public OutputPathAttribute(string builtPath)
{
BuiltPath = builtPath;
}
public string BuiltPath { get; }
}
}

View File

@@ -1,3 +1,4 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Controllers;
@@ -90,6 +91,54 @@ fruit tea:
Assert.Null( parsedDefault[4].AdditionalData);
Assert.Null( parsedDefault[4].PaymentMethods);
}
[Fact]
[Trait("Fast", "Fast")]
public void CanParseAppTemplate()
{
var template = @"[
{
""description"": ""Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years."",
""id"": ""green-tea"",
""image"": ""~/img/pos-sample/green-tea.jpg"",
""priceType"": ""Fixed"",
""price"": ""1"",
""title"": ""Green Tea"",
""disabled"": false
},
{
""description"": ""Tian Jian Tian Jian means 'heavenly tippy tea' in Chinese, and it describes the finest grade of dark tea. Our Tian Jian dark tea is from Hunan province which is famous for making some of the best dark teas available."",
""id"": ""black-tea"",
""image"": ""~/img/pos-sample/black-tea.jpg"",
""priceType"": ""Fixed"",
""price"": ""1"",
""title"": ""Black Tea"",
""disabled"": false
}
]";
var items = AppService.Parse(template);
Assert.Equal(2, items.Length);
Assert.Equal("green-tea", items[0].Id);
Assert.Equal("black-tea", items[1].Id);
// Fails gracefully for missing ID
var missingId = template.Replace(@"""id"": ""green-tea"",", "");
items = AppService.Parse(missingId);
Assert.Single(items);
Assert.Equal("black-tea", items[0].Id);
// Throws for missing ID
Assert.Throws<ArgumentException>(() => AppService.Parse(missingId, true, true));
// Fails gracefully for duplicate IDs
var duplicateId = template.Replace(@"""id"": ""green-tea"",", @"""id"": ""black-tea"",");
items = AppService.Parse(duplicateId);
Assert.Empty(items);
// Throws for duplicate IDs
Assert.Throws<ArgumentException>(() => AppService.Parse(duplicateId, true, true));
}
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]

View File

@@ -45,7 +45,7 @@ namespace BTCPayServer.Tests
var runInBrowser = config["RunSeleniumInBrowser"] == "true";
// Reset this using `dotnet user-secrets remove RunSeleniumInBrowser`
var chromeDriverPath = config["ChromeDriverDirectory"] ?? (Server.PayTester.InContainer ? "/usr/bin" : Directory.GetCurrentDirectory());
var chromeDriverPath = config["ChromeDriverDirectory"] ?? (Server.PayTester.InContainer ? "/usr/bin" : TestUtils.TestDirectory);
var options = new ChromeOptions();
if (!runInBrowser)
@@ -132,11 +132,11 @@ retry:
/// Because for some reason, the selenium container can't resolve the tests container domain name
/// </summary>
public Uri ServerUri;
internal IWebElement FindAlertMessage(StatusMessageModel.StatusSeverity severity = StatusMessageModel.StatusSeverity.Success)
public IWebElement FindAlertMessage(StatusMessageModel.StatusSeverity severity = StatusMessageModel.StatusSeverity.Success)
{
return FindAlertMessage(new[] { severity });
}
internal IWebElement FindAlertMessage(params StatusMessageModel.StatusSeverity[] severity)
public IWebElement FindAlertMessage(params StatusMessageModel.StatusSeverity[] severity)
{
var className = string.Join(", ", severity.Select(statusSeverity => $".alert-{StatusMessageModel.ToString(statusSeverity)}"));
IWebElement el;
@@ -182,10 +182,13 @@ retry:
Driver.FindElement(By.Id("RegisterButton")).Click();
Driver.AssertNoError();
CreatedUser = usr;
Password = "123456";
return usr;
}
string CreatedUser;
public string Password { get; private set; }
public TestAccount AsTestAccount()
{
return new TestAccount(Server) { RegisterDetails = new Models.AccountViewModels.RegisterViewModel() { Password = "123456", Email = CreatedUser } };

View File

@@ -1238,6 +1238,7 @@ namespace BTCPayServer.Tests
await s.StartAsync();
var userId = s.RegisterNewUser(true);
s.CreateNewStore();
s.GenerateWallet();
(_, string appId) = s.CreateApp("PointOfSale");
s.Driver.FindElement(By.Id("Title")).Clear();
s.Driver.FindElement(By.Id("Title")).SendKeys("Tea shop");
@@ -1249,10 +1250,20 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("CodeTabButton")).Click();
var template = s.Driver.FindElement(By.Id("TemplateConfig")).GetAttribute("value");
Assert.Contains("\"buyButtonText\": \"Take my money\"", template);
Assert.Matches("\"categories\": \\[\n\\s+\"Drinks\"\n\\s+\\]", template);
Assert.Matches("\"categories\": \\[\r?\n\\s*\"Drinks\"\\s*\\]", template);
s.ClickPagePrimary();
Assert.Contains("App updated", s.FindAlertMessage().Text);
s.Driver.ScrollTo(By.Id("CodeTabButton"));
s.Driver.FindElement(By.Id("CodeTabButton")).Click();
template = s.Driver.FindElement(By.Id("TemplateConfig")).GetAttribute("value");
s.Driver.FindElement(By.Id("TemplateConfig")).Clear();
s.Driver.FindElement(By.Id("TemplateConfig")).SendKeys(template.Replace(@"""id"": ""green-tea"",", ""));
s.ClickPagePrimary();
Assert.Contains("Invalid template: Missing ID for item \"Green Tea\".", s.Driver.FindElement(By.CssSelector(".validation-summary-errors")).Text);
s.Driver.FindElement(By.Id("ViewApp")).Click();
var windows = s.Driver.WindowHandles;
@@ -1973,6 +1984,7 @@ namespace BTCPayServer.Tests
var jack = new Key().PubKey.Hash.GetAddress(Network.RegTest);
SetTransactionOutput(s, 0, jack, 0.01m);
s.Driver.FindElement(By.Id("SignTransaction")).Click();
s.Driver.WaitForElement(By.CssSelector("button[value=broadcast]"));
Assert.Contains(jack.ToString(), s.Driver.PageSource);
Assert.Contains("0.01000000", s.Driver.PageSource);
Assert.EndsWith("psbt/ready", s.Driver.Url);
@@ -3416,11 +3428,14 @@ namespace BTCPayServer.Tests
var user = s.RegisterNewUser();
s.GoToHome();
s.GoToProfile(ManageNavPages.LoginCodes);
var code = s.Driver.FindElement(By.Id("logincode")).GetAttribute("value");
s.ClickPagePrimary();
Assert.NotEqual(code, s.Driver.FindElement(By.Id("logincode")).GetAttribute("value"));
code = s.Driver.FindElement(By.Id("logincode")).GetAttribute("value");
string code = null;
TestUtils.Eventually(() => { code = s.Driver.FindElement(By.CssSelector("#LoginCode .qr-code")).GetAttribute("alt"); });
string prevCode = code;
await s.Driver.Navigate().RefreshAsync();
TestUtils.Eventually(() => { code = s.Driver.FindElement(By.CssSelector("#LoginCode .qr-code")).GetAttribute("alt"); });
Assert.NotEqual(prevCode, code);
TestUtils.Eventually(() => { code = s.Driver.FindElement(By.CssSelector("#LoginCode .qr-code")).GetAttribute("alt"); });
s.Logout();
s.GoToLogin();
s.Driver.SetAttribute("LoginCode", "value", "bad code");

View File

@@ -146,19 +146,27 @@ namespace BTCPayServer.Tests
public async Task ModifyPayment(Action<GeneralSettingsViewModel> modify)
{
var storeController = GetController<UIStoresController>();
var response = await storeController.GeneralSettings();
GeneralSettingsViewModel settings = (GeneralSettingsViewModel)((ViewResult)response).Model;
var response = await storeController.GeneralSettings(StoreId);
GeneralSettingsViewModel settings = (GeneralSettingsViewModel)((ViewResult)response).Model!;
modify(settings);
await storeController.GeneralSettings(settings);
}
public async Task ModifyGeneralSettings(Action<GeneralSettingsViewModel> modify)
{
var storeController = GetController<UIStoresController>();
var response = await storeController.GeneralSettings(StoreId);
GeneralSettingsViewModel settings = (GeneralSettingsViewModel)((ViewResult)response).Model!;
modify(settings);
storeController.GeneralSettings(settings).GetAwaiter().GetResult();
}
public async Task ModifyOnchainPaymentSettings(Action<WalletSettingsViewModel> modify)
{
var storeController = GetController<UIStoresController>();
var response = await storeController.WalletSettings(StoreId, "BTC");
WalletSettingsViewModel walletSettings = (WalletSettingsViewModel)((ViewResult)response).Model;
modify(walletSettings);
storeController.UpdatePaymentSettings(walletSettings).GetAwaiter().GetResult();
storeController.UpdateWalletSettings(walletSettings).GetAwaiter().GetResult();
}

View File

@@ -1,3 +1,5 @@
Password => Cyphercode
Email address => Cypher ID
Welcome to {0} => Yo at {0}
{
"Password" : "Cyphercode",
"Email address" : "Cypher ID",
"Welcome to {0}" : "Yo at {0}"
}

View File

@@ -16,14 +16,13 @@ namespace BTCPayServer.Tests
public static class TestUtils
{
#if DEBUG && !SHORT_TIMEOUT
public const int TestTimeout = 60_000;
public const int TestTimeout = 600_000;
#else
public const int TestTimeout = 90_000;
#endif
public static DirectoryInfo TryGetSolutionDirectoryInfo(string currentPath = null)
public static DirectoryInfo TryGetSolutionDirectoryInfo()
{
var directory = new DirectoryInfo(
currentPath ?? Directory.GetCurrentDirectory());
var directory = new DirectoryInfo(TestDirectory);
while (directory != null && !directory.GetFiles("*.sln").Any())
{
directory = directory.Parent;
@@ -31,10 +30,15 @@ namespace BTCPayServer.Tests
return directory;
}
static TestUtils()
{
TestDirectory = ((OutputPathAttribute)typeof(TestUtils).Assembly.GetCustomAttributes(typeof(OutputPathAttribute), true)[0]).BuiltPath;
}
public readonly static string TestDirectory;
public static string GetTestDataFullPath(string relativeFilePath)
{
var directory = new DirectoryInfo(Directory.GetCurrentDirectory());
var directory = new DirectoryInfo(TestDirectory);
while (directory != null && !directory.GetFiles("*.csproj").Any())
{
directory = directory.Parent;

View File

@@ -303,7 +303,7 @@ namespace BTCPayServer.Tests
// Set tolerance to 50%
var stores = user.GetController<UIStoresController>();
var response = await stores.GeneralSettings();
var response = await stores.GeneralSettings(user.StoreId);
var vm = Assert.IsType<GeneralSettingsViewModel>(Assert.IsType<ViewResult>(response).Model);
Assert.Equal(0.0, vm.PaymentTolerance);
vm.PaymentTolerance = 50.0;
@@ -385,7 +385,7 @@ namespace BTCPayServer.Tests
await user.RegisterDerivationSchemeAsync("BTC");
await user.RegisterLightningNodeAsync("BTC", LightningConnectionType.CLightning);
await user.SetNetworkFeeMode(NetworkFeeMode.Never);
await user.ModifyOnchainPaymentSettings(p => p.SpeedPolicy = SpeedPolicy.HighSpeed);
await user.ModifyGeneralSettings(p => p.SpeedPolicy = SpeedPolicy.HighSpeed);
var invoice = await user.BitPay.CreateInvoiceAsync(new Invoice(0.0001m, "BTC"));
await tester.WaitForEvent<InvoiceNewPaymentDetailsEvent>(async () =>
{
@@ -445,7 +445,7 @@ namespace BTCPayServer.Tests
var user = tester.NewAccount();
await user.GrantAccessAsync(true);
var storeController = user.GetController<UIStoresController>();
var storeResponse = await storeController.GeneralSettings();
var storeResponse = await storeController.GeneralSettings(user.StoreId);
Assert.IsType<ViewResult>(storeResponse);
Assert.IsType<ViewResult>(storeController.SetupLightningNode(user.StoreId, "BTC"));
@@ -568,10 +568,10 @@ namespace BTCPayServer.Tests
using var tester = CreateServerTester();
await tester.StartAsync();
var acc = tester.NewAccount();
acc.GrantAccess();
await acc.GrantAccessAsync();
acc.RegisterDerivationScheme("BTC");
await acc.ModifyOnchainPaymentSettings(p => p.SpeedPolicy = SpeedPolicy.LowSpeed);
var invoice = acc.BitPay.CreateInvoice(new Invoice
await acc.ModifyGeneralSettings(p => p.SpeedPolicy = SpeedPolicy.LowSpeed);
var invoice = await acc.BitPay.CreateInvoiceAsync(new Invoice
{
Price = 5.0m,
Currency = "USD",
@@ -1463,7 +1463,7 @@ namespace BTCPayServer.Tests
{
Currency = "BTC",
Amount = 1.0m,
PaymentMethods = [ "BTC-CHAIN" ]
PayoutMethods = [ "BTC-CHAIN" ]
});
var controller = user.GetController<UIInvoiceController>();
var invoice = await controller.CreateInvoiceCoreRaw(new()
@@ -1479,7 +1479,7 @@ namespace BTCPayServer.Tests
var payout = Assert.Single(payouts);
Assert.Equal("TOPUP", payout.PayoutMethodId);
Assert.Equal(invoice.Id, payout.Destination);
Assert.Equal(-0.5m, payout.Amount);
Assert.Equal(-0.5m, payout.OriginalAmount);
});
}
@@ -1844,6 +1844,8 @@ namespace BTCPayServer.Tests
await user.GrantAccessAsync();
var user2 = tester.NewAccount();
await user2.GrantAccessAsync();
await user.RegisterDerivationSchemeAsync("BTC");
await user2.RegisterDerivationSchemeAsync("BTC");
var stores = user.GetController<UIStoresController>();
var apps = user.GetController<UIAppsController>();
var apps2 = user2.GetController<UIAppsController>();
@@ -2617,7 +2619,7 @@ namespace BTCPayServer.Tests
}
var controller = tester.PayTester.GetController<UIStoresController>(user.UserId, user.StoreId);
var vm = await controller.GeneralSettings().AssertViewModelAsync<GeneralSettingsViewModel>();
var vm = await controller.GeneralSettings(user.StoreId).AssertViewModelAsync<GeneralSettingsViewModel>();
Assert.Equal(tester.PayTester.ServerUriWithIP + "LocalStorage/8f890691-87f9-4c65-80e5-3b7ffaa3551f-store.png", vm.LogoUrl);
Assert.Equal(tester.PayTester.ServerUriWithIP + "LocalStorage/2a51c49a-9d54-4013-80a2-3f6e69d08523-store.css", vm.CssUrl);

View File

@@ -352,7 +352,7 @@ retry:
}
// Go through all cshtml file, search for text-translate or ViewLocalizer usage
using (var tester = CreateServerTester())
using (var tester = CreateServerTester(newDb: true))
{
await tester.StartAsync();
var engine = tester.PayTester.GetService<RazorProjectEngine>();
@@ -360,12 +360,15 @@ retry:
{
var filePath = file.FullName;
var txt = File.ReadAllText(file.FullName);
if (txt.Contains("ViewLocalizer"))
foreach (string localizer in new[] { "ViewLocalizer", "StringLocalizer" })
{
var matches = Regex.Matches(txt, "ViewLocalizer\\[\"(.*?)\"[\\],]");
foreach (Match match in matches)
if (txt.Contains(localizer))
{
defaultTranslatedKeys.Add(match.Groups[1].Value);
var matches = Regex.Matches(txt, localizer + "\\[\"(.*?)\"[\\],]");
foreach (Match match in matches)
{
defaultTranslatedKeys.Add(match.Groups[1].Value);
}
}
}
@@ -379,12 +382,18 @@ retry:
}
defaultTranslatedKeys = defaultTranslatedKeys.Select(d => d.Trim()).Distinct().OrderBy(o => o).ToList();
JObject obj = new JObject();
foreach (var v in defaultTranslatedKeys)
{
obj.Add(v, "");
}
var path = Path.Combine(soldir.FullName, "BTCPayServer/Services/Translations.Default.cs");
var defaultTranslation = File.ReadAllText(path);
var startIdx = defaultTranslation.IndexOf("\"\"\"");
var endIdx = defaultTranslation.LastIndexOf("\"\"\"");
var content = defaultTranslation.Substring(0, startIdx + 3);
content += "\n" + String.Join('\n', defaultTranslatedKeys) + "\n";
content += "\n" + obj.ToString(Formatting.Indented) + "\n";
content += defaultTranslation.Substring(endIdx);
File.WriteAllText(path, content);
}

View File

@@ -12,7 +12,6 @@ services:
args:
CONFIGURATION_NAME: Release
environment:
TESTS_EXPERIMENTALV2_CONFIRM: "true"
TESTS_BTCRPCCONNECTION: server=http://bitcoind:43782;ceiwHEbqWI83:DwubwWsoo3
TESTS_LTCRPCCONNECTION: server=http://litecoind:43782;ceiwHEbqWI83:DwubwWsoo3
TESTS_BTCNBXPLORERURL: http://nbxplorer:32838/

View File

@@ -12,7 +12,6 @@ services:
args:
CONFIGURATION_NAME: Release
environment:
TESTS_EXPERIMENTALV2_CONFIRM: "true"
TESTS_BTCRPCCONNECTION: server=http://bitcoind:43782;ceiwHEbqWI83:DwubwWsoo3
TESTS_BTCNBXPLORERURL: http://nbxplorer:32838/
TESTS_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=btcpayserver

View File

@@ -7,12 +7,13 @@
<use href="@GetPathTo(Symbol)"></use>
</svg>
@code {
public string GetPathTo(string symbol)
[Parameter, EditorRequired]
public string Symbol { get; set; }
private string GetPathTo(string symbol)
{
var versioned = FileVersionProvider.AddFileVersionToPath(default, "img/icon-sprite.svg");
var rootPath = (BTCPayServerOptions.RootPath ?? "/").WithTrailingSlash();
return $"{rootPath}{versioned}#{Symbol}";
return $"{rootPath}{versioned}#{symbol}";
}
[Parameter]
public string Symbol { get; set; }
}

View File

@@ -0,0 +1,42 @@
@using Microsoft.AspNetCore.Http
@inject IHttpContextAccessor HttpContextAccessor;
@if (Users?.Any() is true)
{
<div @attributes="Attrs" class="@CssClass">
<label for="SignedInUser" class="form-label">Signed in user</label>
<select id="SignedInUser" class="form-select" value="@_userId" @onchange="@(e => _userId = e.Value?.ToString())">
<option value="">None, just open the URL</option>
@foreach (var u in Users)
{
<option value="@u.Key">@u.Value</option>
}
</select>
</div>
}
@if (string.IsNullOrEmpty(_userId))
{
<QrCode Data="@PosUrl" />
}
else
{
<UserLoginCode UserId="@_userId" RedirectUrl="@PosPath" />
}
@code {
[Parameter, EditorRequired]
public string PosPath { get; set; }
[Parameter]
public Dictionary<string,string> Users { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object> Attrs { get; set; }
private string _userId;
private string PosUrl => Request.GetAbsoluteRoot() + PosPath;
private HttpRequest Request => HttpContextAccessor.HttpContext?.Request;
private string CssClass => $"form-group {(Attrs?.ContainsKey("class") is true ? Attrs["class"] : "")}".Trim();
}

View File

@@ -0,0 +1,29 @@
@using QRCoder
@if (!string.IsNullOrEmpty(Data))
{
<img @attributes="Attrs" style="image-rendering:pixelated;image-rendering:-moz-crisp-edges;min-width:@(Size)px;min-height:@(Size)px" src="data:image/png;base64,@(GetBase64(Data))" class="@CssClass" alt="@Data" />
}
@code {
[Parameter, EditorRequired]
public string Data { get; set; }
[Parameter]
public int Size { get; set; } = 256;
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object> Attrs { get; set; }
private static readonly QRCodeGenerator QrGenerator = new();
private string GetBase64(string data)
{
var qrCodeData = QrGenerator.CreateQrCode(data, QRCodeGenerator.ECCLevel.Q);
var qrCode = new PngByteQRCode(qrCodeData);
var bytes = qrCode.GetGraphic(5, [0, 0, 0, 255], [0xf5, 0xf5, 0xf7, 255]);
return Convert.ToBase64String(bytes);
}
private string CssClass => $"qr-code {(Attrs?.ContainsKey("class") is true ? Attrs["class"] : "")}".Trim();
}

View File

@@ -0,0 +1,100 @@
@using System.Timers
@using BTCPayServer.Data
@using BTCPayServer.Fido2
@using Microsoft.AspNetCore.Http
@using Microsoft.AspNetCore.Identity
@using Microsoft.AspNetCore.Mvc
@using Microsoft.AspNetCore.Routing
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject UserManager<ApplicationUser> UserManager;
@inject UserLoginCodeService UserLoginCodeService;
@inject LinkGenerator LinkGenerator;
@inject IHttpContextAccessor HttpContextAccessor;
@implements IDisposable
@if (!string.IsNullOrEmpty(_data))
{
<div @attributes="Attrs" class="@CssClass" style="width:@(Size)px">
<div class="qr-container mb-2">
<QrCode Data="@_data" Size="Size"/>
</div>
<p class="text-center text-muted mb-1" id="progress">Valid for @_seconds seconds</p>
<div class="progress only-for-js" data-bs-toggle="tooltip" data-bs-placement="top">
<div class="progress-bar progress-bar-striped progress-bar-animated @(Percent < 15 ? "bg-warning" : null)" role="progressbar" style="width:@Percent%" id="progressbar"></div>
</div>
</div>
}
@code {
[Parameter]
public string UserId { get; set; }
[Parameter]
public string RedirectUrl { get; set; }
[Parameter]
public int Size { get; set; } = 256;
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object> Attrs { get; set; }
private static readonly double Seconds = UserLoginCodeService.ExpirationTime.TotalSeconds;
private double _seconds = Seconds;
private string _data;
private ApplicationUser _user;
private Timer _timer;
protected override async Task OnParametersSetAsync()
{
UserId ??= await GetUserId();
if (!string.IsNullOrEmpty(UserId)) _user = await UserManager.FindByIdAsync(UserId);
if (_user == null) return;
GenerateCodeAndStartTimer();
}
public void Dispose()
{
_timer?.Dispose();
}
private void GenerateCodeAndStartTimer()
{
var loginCode = UserLoginCodeService.GetOrGenerate(_user.Id);
_data = GetData(loginCode);
_seconds = Seconds;
_timer?.Dispose();
_timer = new Timer(1000);
_timer.Elapsed += CountDownTimer;
_timer.Enabled = true;
}
private void CountDownTimer(object source, ElapsedEventArgs e)
{
if (_seconds > 0)
_seconds -= 1;
else
GenerateCodeAndStartTimer();
InvokeAsync(StateHasChanged);
}
private async Task<string> GetUserId()
{
var state = await AuthenticationStateProvider.GetAuthenticationStateAsync();
return state.User.Identity?.IsAuthenticated is true
? UserManager.GetUserId(state.User)
: null;
}
private string GetData(string loginCode)
{
var req = HttpContextAccessor.HttpContext?.Request;
if (req == null) return loginCode;
return !string.IsNullOrEmpty(RedirectUrl)
? LinkGenerator.LoginCodeLink(loginCode, RedirectUrl, req.Scheme, req.Host, req.PathBase)
: $"{loginCode};{LinkGenerator.IndexLink(req.Scheme, req.Host, req.PathBase)};{_user.Email}";
}
private double Percent => Math.Round(_seconds / Seconds * 100);
private string CssClass => $"user-login-code d-inline-flex flex-column {(Attrs?.ContainsKey("class") is true ? Attrs["class"] : "")}".Trim();
}

View File

@@ -43,7 +43,7 @@
<span text-translate="true">Settings</span>
</a>
</li>
@if (ViewData.IsActivePage([StoreNavPages.General, StoreNavPages.Rates, StoreNavPages.CheckoutAppearance, StoreNavPages.Tokens, StoreNavPages.Users, StoreNavPages.Roles, StoreNavPages.Webhooks, StoreNavPages.PayoutProcessors, StoreNavPages.Emails, StoreNavPages.Forms]))
@if (ViewData.IsPageActive([StoreNavPages.General, StoreNavPages.Rates, StoreNavPages.CheckoutAppearance, StoreNavPages.Tokens, StoreNavPages.Users, StoreNavPages.Roles, StoreNavPages.Webhooks, StoreNavPages.PayoutProcessors, StoreNavPages.Emails, StoreNavPages.Forms]))
{
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyStoreSettings">
<a id="StoreNav-@(nameof(StoreNavPages.Rates))" class="nav-link @ViewData.ActivePageClass(StoreNavPages.Rates)" asp-controller="UIStores" asp-action="Rates" asp-route-storeId="@Model.Store.Id" text-translate="true">Rates</a>
@@ -104,7 +104,7 @@
</a>
}
</li>
@if (ViewData.IsActiveCategory(typeof(WalletsNavPages), scheme.WalletId.ToString()) || ViewData.IsActivePage([WalletsNavPages.Settings], scheme.WalletId.ToString()) || ViewData.IsActivePage([StoreNavPages.OnchainSettings], categoryId))
@if (ViewData.IsCategoryActive(typeof(WalletsNavPages), scheme.WalletId.ToString()) || ViewData.IsPageActive([WalletsNavPages.Settings], scheme.WalletId.ToString()) || ViewData.IsPageActive([StoreNavPages.OnchainSettings], categoryId))
{
<li class="nav-item nav-item-sub">
<a id="WalletNav-Send" class="nav-link @ViewData.ActivePageClass([WalletsNavPages.Send, WalletsNavPages.PSBT], scheme.WalletId.ToString())" asp-area="" asp-controller="UIWallets" asp-action="WalletSend" asp-route-walletId="@scheme.WalletId">Send</a>
@@ -140,7 +140,7 @@
</a>
}
</li>
@if (ViewData.IsActivePage([StoreNavPages.Lightning, StoreNavPages.LightningSettings], $"{Model.Store.Id}-{scheme.CryptoCode}"))
@if (ViewData.IsPageActive([StoreNavPages.Lightning, StoreNavPages.LightningSettings], $"{Model.Store.Id}-{scheme.CryptoCode}"))
{
<li class="nav-item nav-item-sub">
<a id="StoreNav-@(nameof(StoreNavPages.LightningSettings))" class="nav-link @ViewData.ActivePageClass(StoreNavPages.LightningSettings)" asp-controller="UIStores" asp-action="LightningSettings" asp-route-storeId="@Model.Store.Id" asp-route-cryptoCode="@scheme.CryptoCode">Settings</a>
@@ -290,7 +290,7 @@
<span text-translate="true">Server Settings</span>
</a>
</li>
@if (ViewData.IsActiveCategory(typeof(ServerNavPages)) && !ViewData.IsActivePage([ServerNavPages.Plugins]))
@if (ViewData.IsCategoryActive(typeof(ServerNavPages)) && !ViewData.IsPageActive([ServerNavPages.Plugins]))
{
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyServerSettings">
<a asp-controller="UIServer" id="SectionNav-@ServerNavPages.Users" class="nav-link @ViewData.ActivePageClass(ServerNavPages.Users)" asp-action="ListUsers" text-translate="true">Users</a>
@@ -379,7 +379,7 @@
</li>
</ul>
</li>
@if (ViewData.IsActiveCategory(typeof(ManageNavPages)) || ViewData.IsActivePage([ManageNavPages.ChangePassword]))
@if (ViewData.IsCategoryActive(typeof(ManageNavPages)) || ViewData.IsPageActive([ManageNavPages.ChangePassword]))
{
<li class="nav-item nav-item-sub">
<a id="SectionNav-@ManageNavPages.ChangePassword.ToString()" class="nav-link @ViewData.ActivePageClass(ManageNavPages.ChangePassword)" asp-controller="UIManage" asp-action="ChangePassword" text-translate="true">Password</a>

View File

@@ -9,6 +9,7 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Plugins.Crowdfund;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
@@ -28,12 +29,14 @@ namespace BTCPayServer.Controllers.Greenfield
public class GreenfieldAppsController : ControllerBase
{
private readonly AppService _appService;
private readonly UriResolver _uriResolver;
private readonly StoreRepository _storeRepository;
private readonly CurrencyNameTable _currencies;
private readonly UserManager<ApplicationUser> _userManager;
public GreenfieldAppsController(
AppService appService,
UriResolver uriResolver,
StoreRepository storeRepository,
BTCPayNetworkProvider btcPayNetworkProvider,
CurrencyNameTable currencies,
@@ -41,6 +44,7 @@ namespace BTCPayServer.Controllers.Greenfield
)
{
_appService = appService;
_uriResolver = uriResolver;
_storeRepository = storeRepository;
_currencies = currencies;
_userManager = userManager;
@@ -72,12 +76,12 @@ namespace BTCPayServer.Controllers.Greenfield
Archived = request.Archived ?? false
};
var settings = ToCrowdfundSettings(request, new CrowdfundSettings { Title = request.Title ?? request.AppName });
var settings = ToCrowdfundSettings(request);
appData.SetSettings(settings);
await _appService.UpdateOrCreateApp(appData);
return Ok(ToCrowdfundModel(appData));
var model = await ToCrowdfundModel(appData);
return Ok(model);
}
[HttpPost("~/api/v1/stores/{storeId}/apps/pos")]
@@ -208,7 +212,8 @@ namespace BTCPayServer.Controllers.Greenfield
return AppNotFound();
}
return Ok(ToCrowdfundModel(app));
var model = await ToCrowdfundModel(app);
return Ok(model);
}
[HttpDelete("~/api/v1/apps/{appId}")]
@@ -255,7 +260,7 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateAPIError(404, "app-not-found", "The app with specified ID was not found");
}
private CrowdfundSettings ToCrowdfundSettings(CrowdfundAppRequest request, CrowdfundSettings settings)
private CrowdfundSettings ToCrowdfundSettings(CrowdfundAppRequest request)
{
var parsedSounds = ValidateStringArray(request.Sounds);
var parsedColors = ValidateStringArray(request.AnimationColors);
@@ -271,7 +276,7 @@ namespace BTCPayServer.Controllers.Greenfield
Description = request.Description?.Trim(),
EndDate = request.EndDate?.UtcDateTime,
TargetAmount = request.TargetAmount,
MainImageUrl = request.MainImageUrl?.Trim(),
MainImageUrl = request.MainImageUrl == null ? null : UnresolvedUri.Create(request.MainImageUrl),
NotificationUrl = request.NotificationUrl?.Trim(),
Tagline = request.Tagline?.Trim(),
PerksTemplate = request.PerksTemplate is not null ? AppService.SerializeTemplate(AppService.Parse(request.PerksTemplate.Trim())) : null,
@@ -402,16 +407,16 @@ namespace BTCPayServer.Controllers.Greenfield
try
{
// Just checking if we can serialize
AppService.SerializeTemplate(AppService.Parse(request.Template));
AppService.SerializeTemplate(AppService.Parse(request.Template, true, true));
}
catch
catch (Exception ex)
{
ModelState.AddModelError(nameof(request.Template), "Invalid template");
ModelState.AddModelError(nameof(request.Template), ex.Message);
}
}
}
private CrowdfundAppData ToCrowdfundModel(AppData appData)
private async Task<CrowdfundAppData> ToCrowdfundModel(AppData appData)
{
var settings = appData.GetSettings<CrowdfundSettings>();
Enum.TryParse<CrowdfundResetEvery>(settings.ResetEvery.ToString(), true, out var resetEvery);
@@ -432,7 +437,7 @@ namespace BTCPayServer.Controllers.Greenfield
Description = settings.Description,
EndDate = settings.EndDate,
TargetAmount = settings.TargetAmount,
MainImageUrl = settings.MainImageUrl,
MainImageUrl = settings.MainImageUrl == null ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), settings.MainImageUrl),
NotificationUrl = settings.NotificationUrl,
Tagline = settings.Tagline,
DisqusEnabled = settings.DisqusEnabled,
@@ -486,11 +491,11 @@ namespace BTCPayServer.Controllers.Greenfield
try
{
// Just checking if we can serialize
AppService.SerializeTemplate(AppService.Parse(request.PerksTemplate));
AppService.SerializeTemplate(AppService.Parse(request.PerksTemplate, true, true));
}
catch
catch (Exception ex)
{
ModelState.AddModelError(nameof(request.PerksTemplate), "Invalid template");
ModelState.AddModelError(nameof(request.PerksTemplate), $"Invalid template: {ex.Message}");
}
}

View File

@@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -14,6 +15,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payouts;
using BTCPayServer.Rating;
using BTCPayServer.Security;
using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
@@ -25,6 +27,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using NBitcoin;
using NBitpayClient;
using Newtonsoft.Json.Linq;
using CreateInvoiceRequest = BTCPayServer.Client.Models.CreateInvoiceRequest;
using InvoiceData = BTCPayServer.Client.Models.InvoiceData;
@@ -96,11 +99,7 @@ namespace BTCPayServer.Controllers.Greenfield
[FromQuery] int? take = null
)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return StoreNotFound();
}
var store = HttpContext.GetStoreData()!;
if (startDate is DateTimeOffset s &&
endDate is DateTimeOffset e &&
s > e)
@@ -133,17 +132,9 @@ namespace BTCPayServer.Controllers.Greenfield
[HttpGet("~/api/v1/stores/{storeId}/invoices/{invoiceId}")]
public async Task<IActionResult> GetInvoice(string storeId, string invoiceId)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return InvoiceNotFound();
}
var invoice = await _invoiceRepository.GetInvoice(invoiceId, true);
if (invoice?.StoreId != store.Id)
{
if (!BelongsToThisStore(invoice))
return InvoiceNotFound();
}
return Ok(ToModel(invoice));
}
@@ -153,16 +144,9 @@ namespace BTCPayServer.Controllers.Greenfield
[HttpDelete("~/api/v1/stores/{storeId}/invoices/{invoiceId}")]
public async Task<IActionResult> ArchiveInvoice(string storeId, string invoiceId)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return InvoiceNotFound();
}
var invoice = await _invoiceRepository.GetInvoice(invoiceId, true);
if (invoice?.StoreId != store.Id)
{
if (!BelongsToThisStore(invoice))
return InvoiceNotFound();
}
await _invoiceRepository.ToggleInvoiceArchival(invoiceId, true, storeId);
return Ok();
}
@@ -172,19 +156,10 @@ namespace BTCPayServer.Controllers.Greenfield
[HttpPut("~/api/v1/stores/{storeId}/invoices/{invoiceId}")]
public async Task<IActionResult> UpdateInvoice(string storeId, string invoiceId, UpdateInvoiceRequest request)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return InvoiceNotFound();
}
var result = await _invoiceRepository.UpdateInvoiceMetadata(invoiceId, storeId, request.Metadata);
if (result != null)
{
return Ok(ToModel(result));
}
return InvoiceNotFound();
if (!BelongsToThisStore(result))
return InvoiceNotFound();
return Ok(ToModel(result));
}
[Authorize(Policy = Policies.CanCreateInvoice,
@@ -192,12 +167,7 @@ namespace BTCPayServer.Controllers.Greenfield
[HttpPost("~/api/v1/stores/{storeId}/invoices")]
public async Task<IActionResult> CreateInvoice(string storeId, CreateInvoiceRequest request)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return StoreNotFound();
}
var store = HttpContext.GetStoreData()!;
if (request.Amount < 0.0m)
{
ModelState.AddModelError(nameof(request.Amount), "The amount should be 0 or more.");
@@ -271,17 +241,9 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> MarkInvoiceStatus(string storeId, string invoiceId,
MarkInvoiceStatusRequest request)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return InvoiceNotFound();
}
var invoice = await _invoiceRepository.GetInvoice(invoiceId, true);
if (invoice.StoreId != store.Id)
{
if (!BelongsToThisStore(invoice))
return InvoiceNotFound();
}
if (!await _invoiceRepository.MarkInvoiceStatus(invoice.Id, request.Status))
{
@@ -300,17 +262,9 @@ namespace BTCPayServer.Controllers.Greenfield
[HttpPost("~/api/v1/stores/{storeId}/invoices/{invoiceId}/unarchive")]
public async Task<IActionResult> UnarchiveInvoice(string storeId, string invoiceId)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return InvoiceNotFound();
}
var invoice = await _invoiceRepository.GetInvoice(invoiceId, true);
if (invoice.StoreId != store.Id)
{
if (!BelongsToThisStore(invoice))
return InvoiceNotFound();
}
if (!invoice.Archived)
{
@@ -328,21 +282,23 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanViewInvoices,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods")]
public async Task<IActionResult> GetInvoicePaymentMethods(string storeId, string invoiceId, bool onlyAccountedPayments = true)
public async Task<IActionResult> GetInvoicePaymentMethods(string storeId, string invoiceId, bool onlyAccountedPayments = true, bool includeSensitive = false)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return InvoiceNotFound();
}
var invoice = await _invoiceRepository.GetInvoice(invoiceId, true);
if (invoice?.StoreId != store.Id)
{
if (!BelongsToThisStore(invoice))
return InvoiceNotFound();
}
return Ok(ToPaymentMethodModels(invoice, onlyAccountedPayments));
if (includeSensitive && !await _authorizationService.CanModifyStore(User))
return this.CreateAPIPermissionError(Policies.CanModifyStoreSettings);
return Ok(ToPaymentMethodModels(invoice, onlyAccountedPayments, includeSensitive));
}
bool BelongsToThisStore([NotNullWhen(true)] InvoiceEntity invoice) => BelongsToThisStore(invoice, out _);
private bool BelongsToThisStore([NotNullWhen(true)] InvoiceEntity invoice, [MaybeNullWhen(false)] out Data.StoreData store)
{
store = this.HttpContext.GetStoreData();
return invoice?.StoreId is not null && store.Id == invoice.StoreId;
}
[Authorize(Policy = Policies.CanViewInvoices,
@@ -350,17 +306,9 @@ namespace BTCPayServer.Controllers.Greenfield
[HttpPost("~/api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods/{paymentMethod}/activate")]
public async Task<IActionResult> ActivateInvoicePaymentMethod(string storeId, string invoiceId, string paymentMethod)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return InvoiceNotFound();
}
var invoice = await _invoiceRepository.GetInvoice(invoiceId, true);
if (invoice?.StoreId != store.Id)
{
if (!BelongsToThisStore(invoice))
return InvoiceNotFound();
}
if (PaymentMethodId.TryParse(paymentMethod, out var paymentMethodId))
{
@@ -381,22 +329,9 @@ namespace BTCPayServer.Controllers.Greenfield
CancellationToken cancellationToken = default
)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return StoreNotFound();
}
var invoice = await _invoiceRepository.GetInvoice(invoiceId, true);
if (invoice == null)
{
if (!BelongsToThisStore(invoice, out var store))
return InvoiceNotFound();
}
if (invoice.StoreId != store.Id)
{
return InvoiceNotFound();
}
if (!invoice.GetInvoiceState().CanRefund())
{
return this.CreateAPIError("non-refundable", "Cannot refund this invoice");
@@ -446,7 +381,7 @@ namespace BTCPayServer.Controllers.Greenfield
Name = request.Name ?? $"Refund {invoice.Id}",
Description = request.Description,
StoreId = storeId,
PayoutMethodIds = new[] { payoutMethodId },
PayoutMethods = new[] { payoutMethodId },
};
if (request.RefundVariant != RefundVariant.Custom)
@@ -588,12 +523,8 @@ namespace BTCPayServer.Controllers.Greenfield
{
return this.CreateAPIError(404, "invoice-not-found", "The invoice was not found");
}
private IActionResult StoreNotFound()
{
return this.CreateAPIError(404, "store-not-found", "The store was not found");
}
private InvoicePaymentMethodDataModel[] ToPaymentMethodModels(InvoiceEntity entity, bool includeAccountedPaymentOnly)
private InvoicePaymentMethodDataModel[] ToPaymentMethodModels(InvoiceEntity entity, bool includeAccountedPaymentOnly, bool includeSensitive)
{
return entity.GetPaymentPrompts().Select(
prompt =>
@@ -606,7 +537,12 @@ namespace BTCPayServer.Controllers.Greenfield
var details = prompt.Details;
if (handler is not null && prompt.Activated)
details = JToken.FromObject(handler.ParsePaymentPromptDetails(details), handler.Serializer.ForAPI());
{
var detailsObj = handler.ParsePaymentPromptDetails(details);
if (!includeSensitive)
handler.StripDetailsForNonOwner(detailsObj);
details = JToken.FromObject(detailsObj, handler.Serializer.ForAPI());
}
return new InvoicePaymentMethodDataModel
{
Activated = prompt.Activated,
@@ -621,7 +557,7 @@ namespace BTCPayServer.Controllers.Greenfield
PaymentMethodFee = accounting?.PaymentMethodFee ?? 0m,
PaymentLink = (prompt.Activated ? paymentLinkExtension?.GetPaymentLink(prompt, Url) : null) ?? string.Empty,
Payments = payments.Select(paymentEntity => ToPaymentModel(entity, paymentEntity)).ToList(),
AdditionalData = prompt.Details
AdditionalData = details
};
}).ToArray();
}

View File

@@ -0,0 +1,43 @@
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers.GreenField
{
[ApiController]
[EnableCors(CorsPolicies.All)]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public class GreenfieldObsoleteController : ControllerBase
{
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/LNURL")]
public IActionResult Obsolete1(string storeId)
{
return Obsolete();
}
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/LNURLPay/{cryptoCode}")]
[HttpDelete("~/api/v1/stores/{storeId}/payment-methods/LNURLPay/{cryptoCode}")]
[HttpPut("~/api/v1/stores/{storeId}/payment-methods/LNURLPay/{cryptoCode}")]
public IActionResult Obsolete2(string storeId, string cryptoCode)
{
return Obsolete();
}
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/LightningNetwork")]
public IActionResult Obsolete3(string storeId)
{
return Obsolete();
}
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/LightningNetwork/{cryptoCode}")]
[HttpDelete("~/api/v1/stores/{storeId}/payment-methods/LightningNetwork/{cryptoCode}")]
[HttpPut("~/api/v1/stores/{storeId}/payment-methods/LightningNetwork/{cryptoCode}")]
public IActionResult Obsolete4(string storeId, string cryptoCode)
{
return Obsolete();
}
private IActionResult Obsolete()
{
return this.CreateAPIError(410, "unsupported-in-v2", "This route isn't supported by BTCPay Server 2.0 and newer. Please update your integration.");
}
}
}

View File

@@ -36,7 +36,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
Name = factory.Processor,
FriendlyName = factory.FriendlyName,
PaymentMethods = factory.GetSupportedPayoutMethods().Select(id => id.ToString())
PayoutMethods = factory.GetSupportedPayoutMethods().Select(id => id.ToString())
.ToArray()
}));
}

View File

@@ -132,7 +132,7 @@ namespace BTCPayServer.Controllers.Greenfield
ModelState.AddModelError(nameof(request.BOLT11Expiration), $"The BOLT11 expiration should be positive");
}
PayoutMethodId?[]? payoutMethods = null;
if (request.PaymentMethods is { } payoutMethodsStr)
if (request.PayoutMethods is { } payoutMethodsStr)
{
payoutMethods = payoutMethodsStr.Select(s =>
{
@@ -144,13 +144,13 @@ namespace BTCPayServer.Controllers.Greenfield
{
if (!supported.Contains(payoutMethods[i]))
{
request.AddModelError(paymentRequest => paymentRequest.PaymentMethods[i], "Invalid or unsupported payment method", this);
request.AddModelError(paymentRequest => paymentRequest.PayoutMethods[i], "Invalid or unsupported payment method", this);
}
}
}
else
{
ModelState.AddModelError(nameof(request.PaymentMethods), "This field is required");
ModelState.AddModelError(nameof(request.PayoutMethods), "This field is required");
}
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
@@ -364,16 +364,17 @@ namespace BTCPayServer.Controllers.Greenfield
Id = p.Id,
PullPaymentId = p.PullPaymentDataId,
Date = p.Date,
Amount = p.OriginalAmount,
PaymentMethodAmount = p.Amount,
OriginalCurrency = p.OriginalCurrency,
OriginalAmount = p.OriginalAmount,
PayoutCurrency = p.Currency,
PayoutAmount = p.Amount,
Revision = blob.Revision,
State = p.State,
PayoutMethodId = p.PayoutMethodId,
PaymentProof = p.GetProofBlobJson(),
Destination = blob.Destination,
Metadata = blob.Metadata?? new JObject(),
};
model.Destination = blob.Destination;
model.PayoutMethodId = p.PayoutMethodId;
model.CryptoCode = p.Currency;
model.PaymentProof = p.GetProofBlobJson();
return model;
}

View File

@@ -28,7 +28,7 @@ public class GreenfieldServerRolesController : ControllerBase
[HttpGet("~/api/v1/server/roles")]
public async Task<IActionResult> GetServerRoles()
{
return Ok(FromModel(await _storeRepository.GetStoreRoles(null, false, false)));
return Ok(FromModel(await _storeRepository.GetStoreRoles(null, false)));
}
private List<RoleData> FromModel(StoreRepository.StoreRole[] data)
{

View File

@@ -46,7 +46,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
Stores = new[] { storeId },
Processors = new[] { LightningAutomatedPayoutSenderFactory.ProcessorName },
PayoutMethodIds = paymentMethodId is null ? null : new[] { paymentMethodId }
PayoutMethods = paymentMethodId is null ? null : new[] { paymentMethodId }
});
return Ok(configured.Select(ToModel).ToArray());
@@ -88,7 +88,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
Stores = new[] { storeId },
Processors = new[] { LightningAutomatedPayoutSenderFactory.ProcessorName },
PayoutMethodIds = new[] { pmi }
PayoutMethods = new[] { pmi }
}))
.FirstOrDefault();
activeProcessor ??= new PayoutProcessorData();

View File

@@ -47,7 +47,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
Stores = new[] { storeId },
Processors = new[] { OnChainAutomatedPayoutSenderFactory.ProcessorName },
PayoutMethodIds = paymentMethodId is null ? null : new[] { paymentMethodId }
PayoutMethods = paymentMethodId is null ? null : new[] { paymentMethodId }
});
return Ok(configured.Select(ToModel).ToArray());
@@ -94,7 +94,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
Stores = new[] { storeId },
Processors = new[] { OnChainAutomatedPayoutSenderFactory.ProcessorName },
PayoutMethodIds = new[] { payoutMethodId }
PayoutMethods = new[] { payoutMethodId }
}))
.FirstOrDefault();
activeProcessor ??= new PayoutProcessorData();

View File

@@ -145,9 +145,7 @@ namespace BTCPayServer.Controllers.Greenfield
if (includeConfig is true)
{
var canModifyStore = (await _authorizationService.AuthorizeAsync(User, null,
new PolicyRequirement(Policies.CanModifyStoreSettings))).Succeeded;
if (!canModifyStore)
if (!await _authorizationService.CanModifyStore(User))
return this.CreateAPIPermissionError(Policies.CanModifyStoreSettings);
}

View File

@@ -39,7 +39,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
Name = datas.Key,
FriendlyName = _factories.FirstOrDefault(factory => factory.Processor == datas.Key)?.FriendlyName,
PaymentMethods = datas.Select(data => data.PayoutMethodId).ToArray()
PayoutMethods = datas.Select(data => data.PayoutMethodId).ToArray()
});
return Ok(configured);
@@ -55,7 +55,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
Stores = new[] { storeId },
Processors = new[] { processor },
PayoutMethodIds = new[] { PayoutMethodId.Parse(paymentMethod) }
PayoutMethods = new[] { PayoutMethodId.Parse(paymentMethod) }
})).FirstOrDefault();
if (matched is null)
{

View File

@@ -31,7 +31,7 @@ namespace BTCPayServer.Controllers.Greenfield
var store = HttpContext.GetStoreData();
return store == null
? StoreNotFound()
: Ok(FromModel(await _storeRepository.GetStoreRoles(storeId, false, false)));
: Ok(FromModel(await _storeRepository.GetStoreRoles(storeId, false)));
}
private List<RoleData> FromModel(StoreRepository.StoreRole[] data)

View File

@@ -831,10 +831,12 @@ namespace BTCPayServer.Controllers.Greenfield
}
public override async Task<InvoicePaymentMethodDataModel[]> GetInvoicePaymentMethods(string storeId,
string invoiceId, CancellationToken token = default)
string invoiceId,
bool onlyAccountedPayments = true, bool includeSensitive = false,
CancellationToken token = default)
{
return GetFromActionResult<InvoicePaymentMethodDataModel[]>(
await GetController<GreenfieldInvoiceController>().GetInvoicePaymentMethods(storeId, invoiceId));
await GetController<GreenfieldInvoiceController>().GetInvoicePaymentMethods(storeId, invoiceId, onlyAccountedPayments, includeSensitive));
}
public override async Task ArchiveInvoice(string storeId, string invoiceId, CancellationToken token = default)

View File

@@ -123,15 +123,30 @@ namespace BTCPayServer.Controllers
return View(nameof(Login), new LoginViewModel { Email = email });
}
// GET is for signin via the POS backend
[HttpGet("/login/code")]
[AllowAnonymous]
[RateLimitsFilter(ZoneLimits.Login, Scope = RateLimitsScope.RemoteAddress)]
public async Task<IActionResult> LoginUsingCode(string loginCode, string returnUrl = null)
{
return await LoginCodeResult(loginCode, returnUrl);
}
[HttpPost("/login/code")]
[AllowAnonymous]
[ValidateAntiForgeryToken]
[RateLimitsFilter(ZoneLimits.Login, Scope = RateLimitsScope.RemoteAddress)]
public async Task<IActionResult> LoginWithCode(string loginCode, string returnUrl = null)
{
return await LoginCodeResult(loginCode, returnUrl);
}
private async Task<IActionResult> LoginCodeResult(string loginCode, string returnUrl)
{
if (!string.IsNullOrEmpty(loginCode))
{
var userId = _userLoginCodeService.Verify(loginCode);
var code = loginCode.Split(';').First();
var userId = _userLoginCodeService.Verify(code);
if (userId is null)
{
TempData[WellKnownTempData.ErrorMessage] = "Login code was invalid";

View File

@@ -24,12 +24,14 @@ namespace BTCPayServer.Controllers
{
public UIAppsController(
UserManager<ApplicationUser> userManager,
BTCPayNetworkProvider networkProvider,
StoreRepository storeRepository,
IFileService fileService,
AppService appService,
IHtmlHelper html)
{
_userManager = userManager;
_networkProvider = networkProvider;
_storeRepository = storeRepository;
_fileService = fileService;
_appService = appService;
@@ -37,6 +39,7 @@ namespace BTCPayServer.Controllers
}
private readonly UserManager<ApplicationUser> _userManager;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly StoreRepository _storeRepository;
private readonly IFileService _fileService;
private readonly AppService _appService;
@@ -133,6 +136,20 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> CreateApp(string storeId, CreateAppViewModel vm)
{
var store = GetCurrentStore();
if (store == null)
{
return NotFound();
}
if (!store.AnyPaymentMethodAvailable())
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Error,
Html = $"To create a {vm.AppType} app, you need to <a href='{Url.Action(nameof(UIStoresController.SetupWallet), "UIStores", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, storeId })}' class='alert-link'>set up a wallet</a> first",
AllowDismiss = false
});
return View(vm);
}
vm.StoreId = store.Id;
var type = _appService.GetAppType(vm.AppType ?? vm.SelectedAppType);
if (type is null)

View File

@@ -413,7 +413,7 @@ namespace BTCPayServer.Controllers
createPullPayment = new CreatePullPayment
{
Name = $"Refund {invoice.Id}",
PayoutMethodIds = new[] { pmi },
PayoutMethods = new[] { pmi },
StoreId = invoice.StoreId,
BOLT11Expiration = store.GetStoreBlob().RefundBOLT11Expiration
};

View File

@@ -1,21 +1,12 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers
{
public partial class UIManageController
{
[HttpGet]
public async Task<IActionResult> LoginCodes()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
namespace BTCPayServer.Controllers;
return View(nameof(LoginCodes), _userLoginCodeService.GetOrGenerate(user.Id));
}
public partial class UIManageController
{
[HttpGet]
public ActionResult LoginCodes()
{
return View();
}
}

View File

@@ -40,7 +40,6 @@ namespace BTCPayServer.Controllers
private readonly IAuthorizationService _authorizationService;
private readonly Fido2Service _fido2Service;
private readonly LinkGenerator _linkGenerator;
private readonly UserLoginCodeService _userLoginCodeService;
private readonly IHtmlHelper Html;
private readonly UserService _userService;
private readonly UriResolver _uriResolver;
@@ -62,7 +61,6 @@ namespace BTCPayServer.Controllers
UserService userService,
UriResolver uriResolver,
IFileService fileService,
UserLoginCodeService userLoginCodeService,
IHtmlHelper htmlHelper
)
{
@@ -76,7 +74,6 @@ namespace BTCPayServer.Controllers
_authorizationService = authorizationService;
_fido2Service = fido2Service;
_linkGenerator = linkGenerator;
_userLoginCodeService = userLoginCodeService;
Html = htmlHelper;
_userService = userService;
_uriResolver = uriResolver;

View File

@@ -19,7 +19,7 @@ namespace BTCPayServer.Controllers
string sortOrder = null
)
{
var roles = await _StoreRepository.GetStoreRoles(null, true);
var roles = await _StoreRepository.GetStoreRoles(null);
var defaultRole = (await _StoreRepository.GetDefaultRole()).Role;
model ??= new RolesViewModel();
model.DefaultRole = defaultRole;

View File

@@ -150,7 +150,7 @@ namespace BTCPayServer.Controllers
Amount = model.Amount,
Currency = model.Currency,
StoreId = storeId,
PayoutMethodIds = selectedPaymentMethodIds,
PayoutMethods = selectedPaymentMethodIds,
BOLT11Expiration = TimeSpan.FromDays(model.BOLT11Expiration),
AutoApproveClaims = model.AutoApproveClaims
});
@@ -586,7 +586,7 @@ namespace BTCPayServer.Controllers
private async Task<bool> HasPayoutProcessor(string storeId, PayoutMethodId payoutMethodId)
{
var processors = await _payoutProcessorService.GetProcessors(
new PayoutProcessorService.PayoutProcessorQuery { Stores = [storeId], PayoutMethodIds = [payoutMethodId] });
new PayoutProcessorService.PayoutProcessorQuery { Stores = [storeId], PayoutMethods = [payoutMethodId] });
return _payoutProcessorFactories.Any(factory => factory.GetSupportedPayoutMethods().Contains(payoutMethodId)) && processors.Any();
}
private async Task<bool> HasPayoutProcessor(string storeId, string payoutMethodId)

View File

@@ -437,15 +437,10 @@ public partial class UIStoresController
}).ToList(),
Config = ProtectString(JToken.FromObject(derivation, handler.Serializer).ToString()),
PayJoinEnabled = storeBlob.PayJoinEnabled,
MonitoringExpiration = (int)storeBlob.MonitoringExpiration.TotalMinutes,
SpeedPolicy = store.SpeedPolicy,
ShowRecommendedFee = storeBlob.ShowRecommendedFee,
RecommendedFeeBlockTarget = storeBlob.RecommendedFeeBlockTarget,
CanUsePayJoin = canUseHotWallet && network.SupportPayJoin && derivation.IsHotWallet,
CanUseHotWallet = canUseHotWallet,
CanUseRPCImport = rpcImport,
CanUsePayJoin = canUseHotWallet && network.SupportPayJoin && derivation.IsHotWallet,
StoreName = store.StoreName,
StoreName = store.StoreName
};
ViewData["ReplaceDescription"] = WalletReplaceWarning(derivation.IsHotWallet);
@@ -473,15 +468,14 @@ public partial class UIStoresController
var storeBlob = store.GetStoreBlob();
var excludeFilters = storeBlob.GetExcludedPaymentMethods();
var currentlyEnabled = !excludeFilters.Match(handler.PaymentMethodId);
bool enabledChanged = currentlyEnabled != vm.Enabled;
bool needUpdate = enabledChanged;
var enabledChanged = currentlyEnabled != vm.Enabled;
var payjoinChanged = storeBlob.PayJoinEnabled != vm.PayJoinEnabled;
var needUpdate = enabledChanged || payjoinChanged;
string errorMessage = null;
if (enabledChanged)
{
storeBlob.SetExcluded(handler.PaymentMethodId, !vm.Enabled);
store.SetStoreBlob(storeBlob);
}
if (enabledChanged) storeBlob.SetExcluded(handler.PaymentMethodId, !vm.Enabled);
if (payjoinChanged && network.SupportPayJoin) storeBlob.PayJoinEnabled = vm.PayJoinEnabled;
if (needUpdate) store.SetStoreBlob(storeBlob);
if (derivation.Label != vm.Label)
{
@@ -552,6 +546,15 @@ public partial class UIStoresController
_eventAggregator.Publish(new WalletChangedEvent { WalletId = new WalletId(vm.StoreId, vm.CryptoCode) });
successMessage += $" {vm.CryptoCode} on-chain payments are now {(vm.Enabled ? "enabled" : "disabled")} for this store.";
}
if (payjoinChanged && storeBlob.PayJoinEnabled && network.SupportPayJoin)
{
var config = store.GetPaymentMethodConfig<DerivationSchemeSettings>(PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode), _handlers);
if (config?.IsHotWallet is not true)
{
successMessage += " However, PayJoin will not work, as this isn't a <a href='https://docs.btcpayserver.org/HotWallet/' class='alert-link' target='_blank'>hot wallet</a>.";
}
}
TempData[WellKnownTempData.SuccessMessage] = successMessage;
}
@@ -564,65 +567,6 @@ public partial class UIStoresController
return RedirectToAction(nameof(WalletSettings), new { vm.StoreId, vm.CryptoCode });
}
[HttpPost("{storeId}/onchain/{cryptoCode}/settings/payment")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> UpdatePaymentSettings(WalletSettingsViewModel vm)
{
var checkResult = IsAvailable(vm.CryptoCode, out var store, out var network);
if (checkResult != null)
{
return checkResult;
}
var derivation = GetExistingDerivationStrategy(vm.CryptoCode, store);
if (derivation == null)
{
return NotFound();
}
bool needUpdate = false;
var blob = store.GetStoreBlob();
var payjoinChanged = blob.PayJoinEnabled != vm.PayJoinEnabled;
blob.MonitoringExpiration = TimeSpan.FromMinutes(vm.MonitoringExpiration);
blob.ShowRecommendedFee = vm.ShowRecommendedFee;
blob.RecommendedFeeBlockTarget = vm.RecommendedFeeBlockTarget;
blob.PayJoinEnabled = vm.PayJoinEnabled;
if (store.SetStoreBlob(blob))
{
needUpdate = true;
}
if (store.SpeedPolicy != vm.SpeedPolicy)
{
store.SpeedPolicy = vm.SpeedPolicy;
needUpdate = true;
}
if (needUpdate)
{
await _storeRepo.UpdateStore(store);
TempData[WellKnownTempData.SuccessMessage] = "Payment settings successfully updated";
if (payjoinChanged && blob.PayJoinEnabled && network.SupportPayJoin)
{
var config = store.GetPaymentMethodConfig<DerivationSchemeSettings>(PaymentTypes.CHAIN.GetPaymentMethodId(network.CryptoCode), _handlers);
if (config?.IsHotWallet is not true)
{
TempData.Remove(WellKnownTempData.SuccessMessage);
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Warning,
Html = "The payment settings were updated successfully. However, PayJoin will not work, as this isn't a <a href='https://docs.btcpayserver.org/HotWallet/' class='alert-link' target='_blank'>hot wallet</a>."
});
}
}
}
return RedirectToAction(nameof(WalletSettings), new { vm.StoreId, vm.CryptoCode });
}
[HttpGet("{storeId}/onchain/{cryptoCode}/seed")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> WalletSeed(string storeId, string cryptoCode, CancellationToken cancellationToken = default)

View File

@@ -21,7 +21,7 @@ public partial class UIStoresController
string sortOrder = null
)
{
var roles = await storeRepository.GetStoreRoles(storeId, true);
var roles = await storeRepository.GetStoreRoles(storeId);
var defaultRole = (await storeRepository.GetDefaultRole()).Role;
model ??= new RolesViewModel();
model.DefaultRole = defaultRole;

View File

@@ -20,11 +20,11 @@ namespace BTCPayServer.Controllers;
public partial class UIStoresController
{
[HttpGet("{storeId}/settings")]
public async Task<IActionResult> GeneralSettings()
public async Task<IActionResult> GeneralSettings(string storeId)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
if (store == null) return NotFound();
var storeBlob = store.GetStoreBlob();
var vm = new GeneralSettingsViewModel
{
@@ -41,7 +41,11 @@ public partial class UIStoresController
InvoiceExpiration = (int)storeBlob.InvoiceExpiration.TotalMinutes,
DefaultCurrency = storeBlob.DefaultCurrency,
BOLT11Expiration = (long)storeBlob.RefundBOLT11Expiration.TotalDays,
Archived = store.Archived
Archived = store.Archived,
MonitoringExpiration = (int)storeBlob.MonitoringExpiration.TotalMinutes,
SpeedPolicy = store.SpeedPolicy,
ShowRecommendedFee = storeBlob.ShowRecommendedFee,
RecommendedFeeBlockTarget = storeBlob.RecommendedFeeBlockTarget
};
return View(vm);
@@ -67,13 +71,22 @@ public partial class UIStoresController
CurrentStore.StoreWebsite = model.StoreWebsite;
}
if (CurrentStore.SpeedPolicy != model.SpeedPolicy)
{
CurrentStore.SpeedPolicy = model.SpeedPolicy;
needUpdate = true;
}
var blob = CurrentStore.GetStoreBlob();
blob.AnyoneCanInvoice = model.AnyoneCanCreateInvoice;
blob.NetworkFeeMode = model.NetworkFeeMode;
blob.PaymentTolerance = model.PaymentTolerance;
blob.DefaultCurrency = model.DefaultCurrency;
blob.ShowRecommendedFee = model.ShowRecommendedFee;
blob.RecommendedFeeBlockTarget = model.RecommendedFeeBlockTarget;
blob.InvoiceExpiration = TimeSpan.FromMinutes(model.InvoiceExpiration);
blob.RefundBOLT11Expiration = TimeSpan.FromDays(model.BOLT11Expiration);
blob.MonitoringExpiration = TimeSpan.FromMinutes(model.MonitoringExpiration);
if (!string.IsNullOrEmpty(model.BrandColor) && !ColorPalette.IsValid(model.BrandColor))
{
ModelState.AddModelError(nameof(model.BrandColor), "The brand color needs to be a valid hex color code");

View File

@@ -32,7 +32,7 @@ namespace BTCPayServer.Data
#nullable enable
public static PayoutMethodId? GetClosestPayoutMethodId(this InvoiceData invoice, IEnumerable<PayoutMethodId> pmids)
{
var paymentMethodIds = invoice.Payments.Select(o => o.GetPaymentMethodId()).ToArray();
var paymentMethodIds = invoice.Payments.Select(o => PaymentMethodId.Parse(o.PaymentMethodId)).ToArray();
if (paymentMethodIds.Length == 0)
paymentMethodIds = invoice.GetBlob().GetPaymentPrompts().Select(p => p.PaymentMethodId).ToArray();
return PaymentMethodId.GetSimilarities(pmids, paymentMethodIds)

View File

@@ -38,16 +38,12 @@ namespace BTCPayServer.Data
paymentData.Blob2 = JToken.FromObject(blob, InvoiceDataExtensions.DefaultSerializer).ToString(Newtonsoft.Json.Formatting.None);
return paymentData;
}
public static PaymentMethodId GetPaymentMethodId(this PaymentData paymentData)
{
return PaymentMethodId.Parse(paymentData.PaymentMethodId);
}
public static PaymentEntity GetBlob(this PaymentData paymentData)
{
var entity = JToken.Parse(paymentData.Blob2).ToObject<PaymentEntity>(InvoiceDataExtensions.DefaultSerializer) ?? throw new FormatException($"Invalid {nameof(PaymentEntity)}");
entity.Status = paymentData.Status!.Value;
entity.Currency = paymentData.Currency;
entity.PaymentMethodId = GetPaymentMethodId(paymentData);
entity.PaymentMethodId = PaymentMethodId.Parse(paymentData.PaymentMethodId);
entity.Value = paymentData.Amount!.Value;
entity.Id = paymentData.Id;
entity.ReceivedTime = paymentData.Created!.Value;

View File

@@ -9,26 +9,7 @@ namespace BTCPayServer.Data
{
public static PaymentRequestBaseData GetBlob(this PaymentRequestData paymentRequestData)
{
if (paymentRequestData.Blob2 is not null)
{
return paymentRequestData.HasTypedBlob<PaymentRequestBaseData>().GetBlob();
}
#pragma warning disable CS0618 // Type or member is obsolete
else if (paymentRequestData.Blob is not null)
{
return ParseBlob(paymentRequestData.Blob);
}
#pragma warning restore CS0618 // Type or member is obsolete
return new PaymentRequestBaseData();
}
static PaymentRequestBaseData ParseBlob(byte[] blob)
{
var jobj = JObject.Parse(ZipUtils.Unzip(blob));
// Fixup some legacy payment requests
if (jobj["expiryDate"].Type == JTokenType.Date)
jobj["expiryDate"] = new JValue(NBitcoin.Utils.DateTimeToUnixTime(jobj["expiryDate"].Value<DateTime>()));
return jobj.ToObject<PaymentRequestBaseData>();
return paymentRequestData.HasTypedBlob<PaymentRequestBaseData>().GetBlob() ?? new PaymentRequestBaseData();
}
public static void SetBlob(this PaymentRequestData paymentRequestData, PaymentRequestBaseData blob)

View File

@@ -2,6 +2,7 @@ using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Security;
using BTCPayServer.Security.Bitpay;
using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services;
@@ -12,6 +13,11 @@ namespace BTCPayServer
{
public static class AuthorizationExtensions
{
public static async Task<bool> CanModifyStore(this IAuthorizationService authorizationService, ClaimsPrincipal user)
{
return (await authorizationService.AuthorizeAsync(user, null,
new PolicyRequirement(Policies.CanModifyStoreSettings))).Succeeded;
}
public static async Task<(bool HotWallet, bool RPCImport)> CanUseHotWallet(
this IAuthorizationService authorizationService,
PoliciesSettings policiesSettings,

View File

@@ -23,7 +23,7 @@ public static class SettingsRepositoryExtensions
}
return new IssuerKey(issuerKey);
}
internal static AESKey FixedKey()
public static AESKey FixedKey()
{
byte[] v = new byte[16];
v[0] = 1;

View File

@@ -3,7 +3,6 @@ using System;
using BTCPayServer;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
@@ -52,6 +51,12 @@ namespace Microsoft.AspNetCore.Mvc
{
return urlHelper.GetUriByAction(nameof(UIAccountController.Login), "UIAccount", null , scheme, host, pathbase);
}
public static string LoginCodeLink(this LinkGenerator urlHelper, string loginCode, string returnUrl, string scheme, HostString host, string pathbase)
{
return urlHelper.GetUriByAction(nameof(UIAccountController.LoginUsingCode), "UIAccount", new { loginCode, returnUrl }, scheme, host, pathbase);
}
public static string ResetPasswordLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
{
return urlHelper.GetUriByAction(
@@ -109,5 +114,14 @@ namespace Microsoft.AspNetCore.Mvc
values: new { storeId = wallet?.StoreId ?? walletIdOrStoreId, pullPaymentId, payoutState },
scheme, host, pathbase);
}
public static string IndexLink(this LinkGenerator urlHelper, string scheme, HostString host, string pathbase)
{
return urlHelper.GetUriByAction(
action: nameof(UIHomeController.Index),
controller: "UIHome",
values: null,
scheme, host, pathbase);
}
}
}

View File

@@ -8,6 +8,7 @@ namespace BTCPayServer.Fido2
public class UserLoginCodeService
{
private readonly IMemoryCache _memoryCache;
public static readonly TimeSpan ExpirationTime = TimeSpan.FromSeconds(60);
public UserLoginCodeService(IMemoryCache memoryCache)
{
@@ -29,10 +30,10 @@ namespace BTCPayServer.Fido2
}
return _memoryCache.GetOrCreate(GetCacheKey(userId), entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1);
entry.AbsoluteExpirationRelativeToNow = ExpirationTime;
var code = Encoders.Hex.EncodeData(RandomUtils.GetBytes(20));
using var newEntry = _memoryCache.CreateEntry(code);
newEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1);
newEntry.AbsoluteExpirationRelativeToNow = ExpirationTime;
newEntry.Value = userId;
return code;

View File

@@ -192,13 +192,20 @@ namespace BTCPayServer.HostedServices
}
}
private async Task Wait(string invoiceId) => await Wait(await _invoiceRepository.GetInvoice(invoiceId));
private async Task Wait(InvoiceEntity invoice)
private async Task Wait(string invoiceId, bool startup) => await Wait(await _invoiceRepository.GetInvoice(invoiceId), startup);
private async Task Wait(InvoiceEntity invoice, bool startup)
{
var startupOffset = TimeSpan.Zero;
// This give some times for the pollers in the listeners to catch payments which happened
// while the server was down.
if (startup)
startupOffset += TimeSpan.FromMinutes(2.0);
try
{
// add 1 second to ensure watch won't trigger moments before invoice expires
var delay = invoice.ExpirationTime.AddSeconds(1) - DateTimeOffset.UtcNow;
var delay = (invoice.ExpirationTime.AddSeconds(1) + startupOffset) - DateTimeOffset.UtcNow;
if (delay > TimeSpan.Zero)
{
await Task.Delay(delay, _Cts.Token);
@@ -243,7 +250,7 @@ namespace BTCPayServer.HostedServices
if (b.Name == InvoiceEvent.Created)
{
Watch(b.Invoice.Id);
_ = Wait(b.Invoice.Id);
_ = Wait(b.Invoice.Id, false);
}
if (b.Name == InvoiceEvent.ReceivedPayment)
@@ -257,7 +264,7 @@ namespace BTCPayServer.HostedServices
private async Task WaitPendingInvoices()
{
await Task.WhenAll((await GetPendingInvoices(_Cts.Token))
.Select(i => Wait(i)).ToArray());
.Select(i => Wait(i, true)).ToArray());
}
private async Task<InvoiceEntity[]> GetPendingInvoices(CancellationToken cancellationToken)

View File

@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection.Metadata;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Data;
using BTCPayServer.Migrations;
using BTCPayServer.Services.Invoices;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.HostedServices
{
public class PaymentRequestsMigratorHostedService : BlobMigratorHostedService<PaymentRequestData>
{
public PaymentRequestsMigratorHostedService(
ILogger<PaymentRequestsMigratorHostedService> logs,
ISettingsRepository settingsRepository,
ApplicationDbContextFactory applicationDbContextFactory) : base(logs, settingsRepository, applicationDbContextFactory)
{
}
public override string SettingsKey => "PaymentRequestsMigration";
protected override IQueryable<PaymentRequestData> GetQuery(ApplicationDbContext ctx, DateTimeOffset? progress)
{
#pragma warning disable CS0618 // Type or member is obsolete
var query = progress is DateTimeOffset last2 ?
ctx.PaymentRequests.Where(i => i.Created < last2 && !(i.Blob == null && i.Blob2 != null)) :
ctx.PaymentRequests.Where(i => !(i.Blob == null && i.Blob2 != null));
return query.OrderByDescending(i => i.Created);
#pragma warning restore CS0618 // Type or member is obsolete
}
protected override async Task PostMigrationCleanup(ApplicationDbContext ctx, CancellationToken cancellationToken)
{
Logs.LogInformation("Post-migration VACUUM (FULL, ANALYZE)");
await ctx.Database.ExecuteSqlRawAsync("VACUUM (FULL, ANALYZE) \"PaymentRequests\"", cancellationToken);
Logs.LogInformation("Post-migration VACUUM (FULL, ANALYZE) finished");
}
protected override DateTimeOffset ProcessEntities(ApplicationDbContext ctx, List<PaymentRequestData> entities)
{
// The PaymentRequestData.Migrate() is automatically called by EF.
// But Modified isn't set as it happens before the ctx is bound to the entity.
foreach (var entity in entities)
{
ctx.PaymentRequests.Entry(entity).State = EntityState.Modified;
}
return entities[^1].Created;
}
protected override Task Reindex(ApplicationDbContext ctx, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
}

View File

@@ -39,7 +39,7 @@ namespace BTCPayServer.HostedServices
public string Description { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
public PayoutMethodId[] PayoutMethodIds { get; set; }
public PayoutMethodId[] PayoutMethods { get; set; }
public bool AutoApproveClaims { get; set; }
public TimeSpan? BOLT11Expiration { get; set; }
}
@@ -119,7 +119,7 @@ namespace BTCPayServer.HostedServices
Amount = request.Amount,
Currency = request.Currency,
StoreId = storeId,
PayoutMethodIds = request.PaymentMethods.Select(p => PayoutMethodId.Parse(p)).ToArray(),
PayoutMethods = request.PayoutMethods.Select(p => PayoutMethodId.Parse(p)).ToArray(),
AutoApproveClaims = request.AutoApproveClaims
});
}
@@ -143,7 +143,7 @@ namespace BTCPayServer.HostedServices
{
Name = create.Name ?? string.Empty,
Description = create.Description ?? string.Empty,
SupportedPayoutMethods = create.PayoutMethodIds,
SupportedPayoutMethods = create.PayoutMethods,
AutoApproveClaims = create.AutoApproveClaims,
View = new PullPaymentBlob.PullPaymentView
{

View File

@@ -89,6 +89,7 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<IHtmlLocalizerFactory, LocalizerFactory>();
services.TryAddSingleton<LocalizerService>();
services.TryAddSingleton<ViewLocalizer>();
services.TryAddSingleton<IStringLocalizer>(o => o.GetRequiredService<IStringLocalizerFactory>().Create("",""));
services.AddSingleton<MvcNewtonsoftJsonOptions>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value);
services.AddSingleton<JsonSerializerSettings>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value.SerializerSettings);
@@ -578,6 +579,9 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
services.AddSingleton<InvoiceBlobMigratorHostedService>();
services.AddSingleton<IHostedService, InvoiceBlobMigratorHostedService>(o => o.GetRequiredService<InvoiceBlobMigratorHostedService>());
services.AddSingleton<PaymentRequestsMigratorHostedService>();
services.AddSingleton<IHostedService, PaymentRequestsMigratorHostedService>(o => o.GetRequiredService<PaymentRequestsMigratorHostedService>());
// Broken
// Providers.Add("argoneum", new ArgoneumRateProvider(_httpClientFactory?.CreateClient("EXCHANGE_ARGONEUM")));

View File

@@ -64,7 +64,7 @@ namespace BTCPayServer.Hosting
continue;
}
var savedHash = dictionary.Metadata.ToObject<DictionaryFileMetadata>().Hash;
var translations = Translations.CreateFromText(File.ReadAllText(file));
var translations = Translations.CreateFromJson(File.ReadAllText(file));
var currentHash = new uint256(SHA256.HashData(Encoding.UTF8.GetBytes(translations.ToJsonFormat())));
if (savedHash != currentHash)

View File

@@ -211,12 +211,6 @@ namespace BTCPayServer.Hosting
settings.MigrateToStoreConfig = true;
await _Settings.UpdateSetting(settings);
}
if (!settings.MigratePayoutProcessors)
{
await MigratePayoutProcessors();
settings.MigratePayoutProcessors = true;
await _Settings.UpdateSetting(settings);
}
}
catch (Exception ex)
{
@@ -225,17 +219,6 @@ namespace BTCPayServer.Hosting
}
}
private async Task MigratePayoutProcessors()
{
await using var ctx = _DBContextFactory.CreateContext();
var processors = await ctx.PayoutProcessors.ToArrayAsync();
foreach (var processor in processors)
{
processor.PayoutMethodId = processor.GetPayoutMethodId().ToString();
}
await ctx.SaveChangesAsync();
}
private async Task MigrateToStoreConfig()
{
await using var ctx = _DBContextFactory.CreateContext();

View File

@@ -59,5 +59,19 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Minimum acceptable expiration time for BOLT11 for refunds")]
[Range(0, 365 * 10)]
public long BOLT11Expiration { get; set; }
[Display(Name = "Show recommended fee")]
public bool ShowRecommendedFee { get; set; }
[Display(Name = "Recommended fee confirmation target blocks")]
[Range(1, double.PositiveInfinity)]
public int RecommendedFeeBlockTarget { get; set; }
[Display(Name = "Payment invalid if transactions fails to confirm … after invoice expiration")]
[Range(10, 60 * 24 * 24)]
public int MonitoringExpiration { get; set; }
[Display(Name = "Consider the invoice settled when the payment transaction …")]
public SpeedPolicy SpeedPolicy { get; set; }
}
}

View File

@@ -1,6 +1,5 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using BTCPayServer.Client.Models;
using Newtonsoft.Json;
namespace BTCPayServer.Models.StoreViewModels
@@ -16,20 +15,6 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Enable Payjoin/P2EP")]
public bool PayJoinEnabled { get; set; }
[Display(Name = "Show recommended fee")]
public bool ShowRecommendedFee { get; set; }
[Display(Name = "Recommended fee confirmation target blocks")]
[Range(1, double.PositiveInfinity)]
public int RecommendedFeeBlockTarget { get; set; }
[Display(Name = "Payment invalid if transactions fails to confirm … after invoice expiration")]
[Range(10, 60 * 24 * 24)]
public int MonitoringExpiration { get; set; }
[Display(Name = "Consider the invoice settled when the payment transaction …")]
public SpeedPolicy SpeedPolicy { get; set; }
public string Label { get; set; }
public string DerivationSchemeInput { get; set; }

View File

@@ -37,8 +37,6 @@ namespace BTCPayServer.Models.WalletViewModels
public List<PullPaymentModel> PullPayments { get; set; } = new List<PullPaymentModel>();
public override int CurrentPageCount => PullPayments.Count;
public string PaymentMethodId { get; set; }
public IEnumerable<PaymentMethodId> PaymentMethods { get; set; }
public PullPaymentState ActiveState { get; set; } = PullPaymentState.Active;
}

View File

@@ -89,7 +89,10 @@ namespace BTCPayServer.Payments.Bitcoin
{
return ParsePaymentMethodConfig(config);
}
public void StripDetailsForNonOwner(object details)
{
((BitcoinPaymentPromptDetails)details).AccountDerivation = null;
}
public async Task AfterSavingInvoice(PaymentMethodContext paymentMethodContext)
{
var paymentPrompt = paymentMethodContext.Prompt;

View File

@@ -14,6 +14,9 @@ namespace BTCPayServer.Payments.Bitcoin
[JsonConverter(typeof(StringEnumConverter))]
public NetworkFeeMode FeeMode { get; set; }
/// <summary>
/// The fee rate charged to the user as `PaymentMethodFee`.
/// </summary>
[JsonConverter(typeof(NBitcoin.JsonConverters.FeeRateJsonConverter))]
public FeeRate PaymentMethodFeeRate
{
@@ -21,6 +24,10 @@ namespace BTCPayServer.Payments.Bitcoin
set;
}
public bool PayjoinEnabled { get; set; }
/// <summary>
/// The recommended fee rate for this payment method.
/// </summary>
[JsonConverter(typeof(NBitcoin.JsonConverters.FeeRateJsonConverter))]
public FeeRate RecommendedFeeRate { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.KeyPathJsonConverter))]

View File

@@ -67,6 +67,12 @@ namespace BTCPayServer.Payments
/// <param name="details"></param>
/// <returns></returns>
object ParsePaymentPromptDetails(JToken details);
/// <summary>
/// Remove properties from the details which shouldn't appear to non-store owner.
/// </summary>
/// <param name="details">Prompt details</param>
void StripDetailsForNonOwner(object details) { }
/// <summary>
/// Parse the configuration of the payment method in the store
/// </summary>
@@ -115,9 +121,11 @@ namespace BTCPayServer.Payments
}
foreach (var paymentMethodConfig in store.GetPaymentMethodConfigs())
{
var ctx = new PaymentMethodContext(store, storeBlob, paymentMethodConfig.Value, handlers[paymentMethodConfig.Key], invoiceEntity, invoiceLogs);
if (!handlers.TryGetValue(paymentMethodConfig.Key, out var handler))
continue;
var ctx = new PaymentMethodContext(store, storeBlob, paymentMethodConfig.Value, handler, invoiceEntity, invoiceLogs);
PaymentMethodContexts.Add(paymentMethodConfig.Key, ctx);
if (excludeFilter.Match(paymentMethodConfig.Key) || !handlers.Support(paymentMethodConfig.Key))
if (excludeFilter.Match(paymentMethodConfig.Key))
ctx.Status = PaymentMethodContext.ContextStatus.Excluded;
}
}

View File

@@ -121,12 +121,7 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Li
var processorBlob = GetBlob(PayoutProcessorSettings);
var lightningSupportedPaymentMethod = (LightningPaymentMethodConfig)paymentMethodConfig;
if (lightningSupportedPaymentMethod.IsInternalNode &&
!(await Task.WhenAll((await _storeRepository.GetStoreUsers(PayoutProcessorSettings.StoreId))
.Where(user =>
user.StoreRole.ToPermissionSet(PayoutProcessorSettings.StoreId)
.Contains(Policies.CanModifyStoreSettings, PayoutProcessorSettings.StoreId))
.Select(user => user.Id)
.Select(s => _userService.IsAdminUser(s)))).Any(b => b))
!await _storeRepository.InternalNodePayoutAuthorized(PayoutProcessorSettings.StoreId))
{
return false;
}

View File

@@ -54,7 +54,7 @@ public class UILightningAutomatedPayoutProcessorsController : Controller
{
Stores = new[] { storeId },
Processors = new[] { _lightningAutomatedPayoutSenderFactory.Processor },
PayoutMethodIds = new[]
PayoutMethods = new[]
{
PayoutTypes.LN.GetPayoutMethodId(cryptoCode)
}
@@ -88,7 +88,7 @@ public class UILightningAutomatedPayoutProcessorsController : Controller
{
Stores = new[] { storeId },
Processors = new[] { _lightningAutomatedPayoutSenderFactory.Processor },
PayoutMethodIds = new[]
PayoutMethods = new[]
{
PayoutTypes.LN.GetPayoutMethodId(cryptoCode)
}

View File

@@ -65,7 +65,7 @@ public class UIOnChainAutomatedPayoutProcessorsController : Controller
{
Stores = new[] { storeId },
Processors = new[] { _onChainAutomatedPayoutSenderFactory.Processor },
PayoutMethodIds = new[]
PayoutMethods = new[]
{
PayoutTypes.CHAIN.GetPayoutMethodId(cryptoCode)
}
@@ -98,7 +98,7 @@ public class UIOnChainAutomatedPayoutProcessorsController : Controller
{
Stores = new[] { storeId },
Processors = new[] { OnChainAutomatedPayoutSenderFactory.ProcessorName },
PayoutMethodIds = new[]
PayoutMethods = new[]
{
PayoutTypes.CHAIN.GetPayoutMethodId(cryptoCode)
}

View File

@@ -53,11 +53,11 @@ public class PayoutProcessorService : EventHostedServiceBase
public PayoutProcessorQuery(string storeId, PayoutMethodId payoutMethodId)
{
Stores = new[] { storeId };
PayoutMethodIds = new[] { payoutMethodId };
PayoutMethods = new[] { payoutMethodId };
}
public string[] Stores { get; set; }
public string[] Processors { get; set; }
public PayoutMethodId[] PayoutMethodIds { get; set; }
public PayoutMethodId[] PayoutMethods { get; set; }
}
public async Task<List<PayoutProcessorData>> GetProcessors(PayoutProcessorQuery query)
@@ -73,9 +73,9 @@ public class PayoutProcessorService : EventHostedServiceBase
{
queryable = queryable.Where(data => query.Stores.Contains(data.StoreId));
}
if (query.PayoutMethodIds is not null)
if (query.PayoutMethods is not null)
{
var paymentMethods = query.PayoutMethodIds.Select(d => d.ToString()).Distinct().ToArray();
var paymentMethods = query.PayoutMethods.Select(d => d.ToString()).Distinct().ToArray();
queryable = queryable.Where(data => paymentMethods.Contains(data.PayoutMethodId));
}

View File

@@ -4,8 +4,10 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Form;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
@@ -44,6 +46,7 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
EventAggregator eventAggregator,
UriResolver uriResolver,
StoreRepository storeRepository,
IFileService fileService,
UIInvoiceController invoiceController,
UserManager<ApplicationUser> userManager,
FormDataService formDataService,
@@ -53,6 +56,7 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
_appService = appService;
_userManager = userManager;
_app = app;
_fileService = fileService;
_storeRepository = storeRepository;
_eventAggregator = eventAggregator;
_uriResolver = uriResolver;
@@ -61,6 +65,7 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
}
private readonly EventAggregator _eventAggregator;
private readonly IFileService _fileService;
private readonly UriResolver _uriResolver;
private readonly CurrencyNameTable _currencies;
private readonly StoreRepository _storeRepository;
@@ -393,6 +398,7 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
var settings = app.GetSettings<CrowdfundSettings>();
var resetEvery = Enum.GetName(typeof(CrowdfundResetEvery), settings.ResetEvery);
var vm = new UpdateCrowdfundViewModel
{
Title = settings.Title,
@@ -405,8 +411,8 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
EnforceTargetAmount = settings.EnforceTargetAmount,
StartDate = settings.StartDate,
TargetCurrency = settings.TargetCurrency,
MainImageUrl = settings.MainImageUrl == null ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), settings.MainImageUrl),
Description = settings.Description,
MainImageUrl = settings.MainImageUrl,
EndDate = settings.EndDate,
TargetAmount = settings.TargetAmount,
NotificationUrl = settings.NotificationUrl,
@@ -434,8 +440,13 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpPost("{appId}/settings/crowdfund")]
public async Task<IActionResult> UpdateCrowdfund(string appId, UpdateCrowdfundViewModel vm, string command)
public async Task<IActionResult> UpdateCrowdfund(string appId, UpdateCrowdfundViewModel vm,
[FromForm] bool RemoveLogoFile = false)
{
var userId = GetUserId();
if (userId is null)
return NotFound();
var app = GetCurrentApp();
if (app == null)
return NotFound();
@@ -447,11 +458,11 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
try
{
vm.PerksTemplate = AppService.SerializeTemplate(AppService.Parse(vm.PerksTemplate));
vm.PerksTemplate = AppService.SerializeTemplate(AppService.Parse(vm.PerksTemplate, true, true));
}
catch
catch (Exception ex)
{
ModelState.AddModelError(nameof(vm.PerksTemplate), "Invalid template");
ModelState.AddModelError(nameof(vm.PerksTemplate), $"Invalid template: {ex.Message}");
}
if (vm.TargetAmount is decimal v && v == 0.0m)
{
@@ -503,6 +514,16 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
parsedAnimationColors = new CrowdfundSettings().AnimationColors;
}
UploadImageResultModel imageUpload = null;
if (vm.MainImageFile != null)
{
imageUpload = await _fileService.UploadImage(vm.MainImageFile, userId);
if (!imageUpload.Success)
{
ModelState.AddModelError(nameof(vm.MainImageFile), imageUpload.Response);
}
}
if (!ModelState.IsValid)
{
return View("Crowdfund/UpdateCrowdfund", vm);
@@ -520,7 +541,7 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
Description = vm.Description,
EndDate = vm.EndDate?.ToUniversalTime(),
TargetAmount = vm.TargetAmount,
MainImageUrl = vm.MainImageUrl,
MainImageUrl = app.GetSettings<CrowdfundSettings>()?.MainImageUrl,
NotificationUrl = vm.NotificationUrl,
Tagline = vm.Tagline,
PerksTemplate = vm.PerksTemplate,
@@ -538,6 +559,17 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
FormId = vm.FormId
};
if (imageUpload?.Success is true)
{
newSettings.MainImageUrl = new UnresolvedUri.FileIdUri(imageUpload.StoredFile.Id);
}
else if (RemoveLogoFile)
{
newSettings.MainImageUrl = null;
vm.MainImageUrl = null;
vm.MainImageFile = null;
}
app.TagAllInvoices = vm.UseAllStoreInvoices;
app.SetSettings(newSettings);

View File

@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services;
using BTCPayServer.Client.Models;
@@ -49,22 +50,28 @@ namespace BTCPayServer.Plugins.Crowdfund
private readonly IOptions<BTCPayServerOptions> _options;
private readonly DisplayFormatter _displayFormatter;
private readonly CurrencyNameTable _currencyNameTable;
private readonly UriResolver _uriResolver;
private readonly InvoiceRepository _invoiceRepository;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly PrettyNameProvider _prettyNameProvider;
public const string AppType = "Crowdfund";
public CrowdfundAppType(
LinkGenerator linkGenerator,
IOptions<BTCPayServerOptions> options,
UriResolver uriResolver,
InvoiceRepository invoiceRepository,
PrettyNameProvider prettyNameProvider,
DisplayFormatter displayFormatter,
IHttpContextAccessor httpContextAccessor,
CurrencyNameTable currencyNameTable)
{
Description = Type = AppType;
_linkGenerator = linkGenerator;
_options = options;
_uriResolver = uriResolver;
_displayFormatter = displayFormatter;
_httpContextAccessor = httpContextAccessor;
_currencyNameTable = currencyNameTable;
_invoiceRepository = invoiceRepository;
_prettyNameProvider = prettyNameProvider;
@@ -186,12 +193,11 @@ namespace BTCPayServer.Plugins.Crowdfund
? _linkGenerator.GetPathByAction(nameof(UICrowdfundController.CrowdfundForm), "UICrowdfund",
new { appId = appData.Id }, _options.Value.RootPath)
: null;
return new ViewCrowdfundViewModel
var vm = new ViewCrowdfundViewModel
{
Title = settings.Title,
Tagline = settings.Tagline,
Description = settings.Description,
MainImageUrl = settings.MainImageUrl,
StoreName = store.StoreName,
StoreId = appData.StoreDataId,
AppId = appData.Id,
@@ -230,6 +236,12 @@ namespace BTCPayServer.Plugins.Crowdfund
CurrentAmount = currentPayments.TotalCurrency
}
};
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext != null && settings.MainImageUrl != null)
{
vm.MainImageUrl = await _uriResolver.Resolve(httpContext.Request.GetAbsoluteRootUri(), settings.MainImageUrl);
}
return vm;
}
private Dictionary<string, PaymentStat> GetPaymentStats(InvoiceStatistics stats)

View File

@@ -4,6 +4,8 @@ using System.ComponentModel.DataAnnotations;
using System.Linq;
using BTCPayServer.Services.Apps;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;
namespace BTCPayServer.Plugins.Crowdfund.Models
{
@@ -32,6 +34,10 @@ namespace BTCPayServer.Plugins.Crowdfund.Models
[Display(Name = "Featured Image URL")]
public string MainImageUrl { get; set; }
[Display(Name = "Featured Image URL")]
[JsonIgnore]
public IFormFile MainImageFile { get; set; }
[Display(Name = "Callback Notification URL")]
[Uri]
public string NotificationUrl { get; set; }

View File

@@ -627,6 +627,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
}
vm.ExampleCallback = "{\n \"id\":\"SkdsDghkdP3D3qkj7bLq3\",\n \"url\":\"https://btcpay.example.com/invoice?id=SkdsDghkdP3D3qkj7bLq3\",\n \"status\":\"paid\",\n \"price\":10,\n \"currency\":\"EUR\",\n \"invoiceTime\":1520373130312,\n \"expirationTime\":1520374030312,\n \"currentTime\":1520373179327,\n \"exceptionStatus\":false,\n \"buyerFields\":{\n \"buyerEmail\":\"customer@example.com\",\n \"buyerNotify\":false\n },\n \"paymentSubtotals\": {\n \"BTC\":114700\n },\n \"paymentTotals\": {\n \"BTC\":118400\n },\n \"transactionCurrency\": \"BTC\",\n \"amountPaid\": \"1025900\",\n \"exchangeRates\": {\n \"BTC\": {\n \"EUR\": 8721.690715789999,\n \"USD\": 10817.99\n }\n }\n}";
await FillUsers(vm);
return View("PointOfSale/UpdatePointOfSale", vm);
}
@@ -647,14 +649,15 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
ModelState.AddModelError(nameof(vm.Currency), "Invalid currency");
try
{
vm.Template = AppService.SerializeTemplate(AppService.Parse(vm.Template));
vm.Template = AppService.SerializeTemplate(AppService.Parse(vm.Template, true, true));
}
catch
catch (Exception ex)
{
ModelState.AddModelError(nameof(vm.Template), "Invalid template");
ModelState.AddModelError(nameof(vm.Template), $"Invalid template: {ex.Message}");
}
if (!ModelState.IsValid)
{
await FillUsers(vm);
return View("PointOfSale/UpdatePointOfSale", vm);
}
@@ -715,5 +718,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
private StoreData GetCurrentStore() => HttpContext.GetStoreData();
private AppData GetCurrentApp() => HttpContext.GetAppData();
private async Task FillUsers(UpdatePointOfSaleViewModel vm)
{
var users = await _storeRepository.GetStoreUsers(GetCurrentStore().Id);
vm.StoreUsers = users.Select(u => (u.Id, u.Email, u.StoreRole.Role)).ToDictionary(u => u.Id, u => $"{u.Email} ({u.Role})");
}
}
}

View File

@@ -68,6 +68,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
public string CustomTipPercentages { get; set; }
public string Id { get; set; }
public Dictionary<string, string> StoreUsers { get; set; }
[Display(Name = "Redirect invoice to redirect url automatically after paid")]
public string RedirectAutomatically { get; set; } = string.Empty;

View File

@@ -44,13 +44,6 @@ namespace BTCPayServer
confBuilder.AddJsonFile("appsettings.dev.json", true, false);
#endif
conf = confBuilder.Build();
var confirm = conf.GetOrDefault<bool>("EXPERIMENTALV2_CONFIRM", false);
if(!confirm)
{
throw new ConfigException("You are running an experimental version of BTCPay Server that is the basis for v2. Many things will change and break, including irreversible database migrations. THERE IS NO WAY BACK. Please confirm you understand this by setting the setting EXPERIMENTALV2_CONFIRM=true");
}
var builder = new WebHostBuilder()
.UseKestrel()
.UseConfiguration(conf)

View File

@@ -4,7 +4,6 @@
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"BTCPAY_EXPERIMENTALV2_CONFIRM": "true",
"BTCPAY_NETWORK": "regtest",
"BTCPAY_LAUNCHSETTINGS": "true",
"BTCPAY_BTCLIGHTNING": "type=clightning;server=tcp://127.0.0.1:30993/",
@@ -38,7 +37,6 @@
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"BTCPAY_EXPERIMENTALV2_CONFIRM": "true",
"BTCPAY_NETWORK": "regtest",
"BTCPAY_LAUNCHSETTINGS": "true",
"BTCPAY_PORT": "14142",
@@ -76,7 +74,6 @@
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"BTCPAY_EXPERIMENTALV2_CONFIRM": "true",
"BTCPAY_NETWORK": "regtest",
"BTCPAY_LAUNCHSETTINGS": "true",
"BTCPAY_PORT": "14142",
@@ -117,7 +114,6 @@
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"BTCPAY_EXPERIMENTALV2_CONFIRM": "true",
"BTCPAY_NETWORK": "regtest",
"BTCPAY_LAUNCHSETTINGS": "true",
"BTCPAY_PORT": "14142",

View File

@@ -106,15 +106,15 @@ namespace BTCPayServer.Services.Apps
Date = entities.Key,
Label = entities.Key.ToString("MMM dd", CultureInfo.InvariantCulture),
SalesCount = entities.Count()
});
}).ToList();
// fill up the gaps
foreach (var i in Enumerable.Range(0, numberOfDays))
{
var date = (DateTimeOffset.UtcNow - TimeSpan.FromDays(i)).Date;
if (!series.Any(e => e.Date == date))
if (series.All(e => e.Date != date))
{
series = series.Append(new AppSalesStatsItem
series.Add(new AppSalesStatsItem
{
Date = date,
Label = date.ToString("MMM dd", CultureInfo.InvariantCulture)
@@ -125,7 +125,7 @@ namespace BTCPayServer.Services.Apps
return Task.FromResult(new AppSalesStats
{
SalesCount = series.Sum(i => i.SalesCount),
Series = series.OrderBy(i => i.Label)
Series = series.OrderBy(i => i.Date)
});
}
@@ -348,12 +348,17 @@ namespace BTCPayServer.Services.Apps
{
return JsonConvert.SerializeObject(items, Formatting.Indented, _defaultSerializer);
}
public static ViewPointOfSaleViewModel.Item[] Parse(string template, bool includeDisabled = true)
public static ViewPointOfSaleViewModel.Item[] Parse(string template, bool includeDisabled = true, bool throws = false)
{
if (string.IsNullOrWhiteSpace(template))
return Array.Empty<ViewPointOfSaleViewModel.Item>();
return JsonConvert.DeserializeObject<ViewPointOfSaleViewModel.Item[]>(template, _defaultSerializer)!.Where(item => includeDisabled || !item.Disabled).ToArray();
if (string.IsNullOrWhiteSpace(template)) return [];
var allItems = JsonConvert.DeserializeObject<ViewPointOfSaleViewModel.Item[]>(template, _defaultSerializer)!;
// ensure all items have an id, which is also unique
var itemsWithoutId = allItems.Where(i => string.IsNullOrEmpty(i.Id)).ToList();
if (itemsWithoutId.Any() && throws) throw new ArgumentException($"Missing ID for item \"{itemsWithoutId.First().Title}\".");
// find items with duplicate IDs
var duplicateIds = allItems.GroupBy(i => i.Id).Where(g => g.Count() > 1).Select(g => g.Key).ToList();
if (duplicateIds.Any() && throws) throw new ArgumentException($"Duplicate ID \"{duplicateIds.First()}\".");
return allItems.Where(item => (includeDisabled || !item.Disabled) && !itemsWithoutId.Contains(item) && !duplicateIds.Contains(item.Id)).ToArray();
}
#nullable restore
#nullable enable

View File

@@ -1,4 +1,6 @@
using System;
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Services.Apps
{
@@ -27,7 +29,8 @@ namespace BTCPayServer.Services.Apps
}
public bool EnforceTargetAmount { get; set; }
public string MainImageUrl { get; set; }
[JsonConverter(typeof(UnresolvedUriJsonConverter))]
public UnresolvedUri MainImageUrl { get; set; }
public string NotificationUrl { get; set; }
public string Tagline { get; set; }
public string PerksTemplate { get; set; }

View File

@@ -871,14 +871,18 @@ namespace BTCPayServer.Services.Invoices
[JsonIgnore]
public decimal Rate => Currency is null ? throw new InvalidOperationException("Currency of the payment prompt isn't set") : ParentEntity.GetInvoiceRate(Currency);
public int Divisibility { get; set; }
/// <summary>
/// Total additional fee imposed by this specific payment method.
/// It includes the <see cref="TweakFee"/>.
/// </summary>
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal PaymentMethodFee { get; set; }
/// <summary>
/// A fee, hidden from UI, meant to be used when a payment method has a service provider which
/// An additional fee, hidden from UI, meant to be used when a payment method has a service provider which
/// have a different way of converting the invoice's amount into the currency of the payment method.
/// This fee can avoid under/over payments when this case happens.
///
/// Please use <see cref="AddTweakFee(decimal)"/> so that the tweak fee is also added to the <see cref="PaymentMethodFee"/>.
/// You need to increment it with <see cref="AddTweakFee(decimal)"/> so that the tweak fee is also added to the <see cref="PaymentMethodFee"/>.
/// </summary>
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal TweakFee { get; set; }

View File

@@ -104,27 +104,31 @@ namespace BTCPayServer.Services.Invoices
/// <returns></returns>
public async Task<InvoiceEntity[]> GetMonitoredInvoices(PaymentMethodId paymentMethodId, bool includeNonActivated, CancellationToken cancellationToken = default)
{
var pmi = paymentMethodId.ToString();
using var ctx = _applicationDbContextFactory.CreateContext();
var conn = ctx.Database.GetDbConnection();
string includeNonActivateQuery = String.Empty;
if (includeNonActivated)
includeNonActivateQuery = " AND (get_prompt(i.\"Blob2\", @pmi)->'activated')::BOOLEAN IS NOT FALSE)";
var rows = await conn.QueryAsync<(string Id, uint xmin, string[] addresses, string[] payments, string invoice)>(new("""
WITH invoices_payments AS (
SELECT
i."Id",
i.xmin,
array_agg(ai."Address") addresses,
COALESCE(array_agg(to_jsonb(p)) FILTER (WHERE p."Id" IS NOT NULL), '{}') as payments,
(array_agg(to_jsonb(i)))[1] as invoice
m.invoice_id,
array_agg(to_jsonb(p)) FILTER (WHERE p."Id" IS NOT NULL) as payments
FROM get_monitored_invoices(@pmi, @includeNonActivated) m
LEFT JOIN "Payments" p ON p."Id" = m.payment_id AND p."PaymentMethodId" = m.payment_method_id
LEFT JOIN "Invoices" i ON i."Id" = m.invoice_id
LEFT JOIN "AddressInvoices" ai ON i."Id" = ai."InvoiceDataId"
GROUP BY 1
),
invoices_addresses AS (
SELECT m.invoice_id,
array_agg(ai."Address") addresses
FROM get_monitored_invoices(@pmi, @includeNonActivated) m
JOIN "AddressInvoices" ai ON ai."InvoiceDataId" = m.invoice_id
WHERE ai."PaymentMethodId" = @pmi
GROUP BY i."Id";
GROUP BY 1
)
SELECT
ip.invoice_id, i.xmin, COALESCE(ia.addresses, '{}'), COALESCE(ip.payments, '{}'), to_jsonb(i)
FROM invoices_payments ip
JOIN "Invoices" i ON i."Id" = ip.invoice_id
LEFT JOIN invoices_addresses ia ON ia.invoice_id = ip.invoice_id;
"""
, new { pmi = paymentMethodId.ToString(), includeNonActivated }));
if (Enumerable.TryGetNonEnumeratedCount(rows, out var c) && c == 0)

View File

@@ -31,6 +31,5 @@ namespace BTCPayServer.Services
public bool FixMappedDomainAppType { get; set; }
public bool MigrateAppYmlToJson { get; set; }
public bool MigrateToStoreConfig { get; set; }
public bool MigratePayoutProcessors { get; internal set; }
}
}

View File

@@ -9,10 +9,12 @@ using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Migrations;
using Dapper;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.DataEncoders;
using Newtonsoft.Json;
using static BTCPayServer.Services.Stores.StoreRepository;
namespace BTCPayServer.Services.Stores
{
@@ -80,14 +82,20 @@ namespace BTCPayServer.Services.Stores
public bool? IsUsed { get; set; }
}
#nullable enable
public async Task<StoreRole[]> GetStoreRoles(string? storeId, bool includeUsers = false, bool storeOnly = false)
public async Task<StoreRole[]> GetStoreRoles(string? storeId, bool storeOnly = false)
{
await using var ctx = _ContextFactory.CreateContext();
var query = ctx.StoreRoles.Where(u => (storeOnly && u.StoreDataId == storeId) || (!storeOnly && (u.StoreDataId == null || u.StoreDataId == storeId)));
if (includeUsers)
{
query = query.Include(u => u.Users);
}
var query = ctx.StoreRoles
.Where(u => (storeOnly && u.StoreDataId == storeId) || (!storeOnly && (u.StoreDataId == null || u.StoreDataId == storeId)))
// Not calling ToStoreRole here because we don't want to load users in the DB query
.Select(u => new StoreRole()
{
Id = u.Id,
Role = u.Role,
Permissions = u.Permissions,
IsServerRole = u.StoreDataId == null,
IsUsed = u.Users.Any()
});
var roles = await query.ToArrayAsync();
// return ordered: default role comes first, then server-wide roles in specified order, followed by store roles
@@ -98,7 +106,7 @@ namespace BTCPayServer.Services.Stores
if (role.Role == defaultRole.Role) return -1;
int index = Array.IndexOf(defaultOrder, role.Role);
return index == -1 ? int.MaxValue : index;
}).Select(ToStoreRole).ToArray();
}).ToArray();
}
public async Task<StoreRoleId> GetDefaultRole()
@@ -637,6 +645,23 @@ retry:
{
return ex.InnerException is Npgsql.PostgresException postgres && postgres.SqlState == "40P01";
}
public async Task<bool> InternalNodePayoutAuthorized(string storeId)
{
using var ctx = _ContextFactory.CreateContext();
return (await ctx.Database.GetDbConnection().ExecuteScalarAsync<bool?>("""
SELECT TRUE
FROM "UserStore" us
JOIN "StoreRoles" sr ON sr."Id" = us."Role"
JOIN "AspNetUserRoles" ur ON us."ApplicationUserId" = ur."UserId"
JOIN "AspNetRoles" r ON ur."RoleId" = r."Id"
WHERE
us."StoreDataId"=@storeId AND
r."NormalizedName"='SERVERADMIN' AND
'btcpay.store.canmodifystoresettings' = ANY(sr."Permissions")
LIMIT 1;
""", new { storeId })) is true;
}
}
public record StoreRoleId

View File

@@ -11,307 +11,377 @@ namespace BTCPayServer.Services
// Please run it before release.
var knownTranslations =
"""
Access Tokens
Account
Account key
Account key path
Add additional fee (network fee) to invoice
Add Address
Add Exchange Rate Spread
Add hop hints for private channels to the Lightning invoice
Add Role
Add Service
Add User
Add Webhook
Additional Actions
Admin API access token
Admin must approve new users
Administrator
Allow anyone to create invoice
Allow form for public use
Allow payee to create invoices with custom amounts
Allow payee to pass a comment
Allow Stores use the Server's SMTP email settings as their default
Always include non-witness UTXO if available
Amazon S3
Amount
API Key
API Keys
App
App Name
App Type
Application
Approve
Archive this store
Authenticator code
Auto-detect language on checkout
Automatically approve claims
Available Payment Methods
Azure Blob Storage
Backend's language
Batch size
BIP39 Seed (12/24 word mnemonic phrase) or HD private key (xprv...)
Brand Color
Branding
Buyer Email
Callback Notification URL
Can use hot wallet
Can use RPC import
Celebrate payment with confetti
Check releases on GitHub and notify when new BTCPay Server version is available
Checkout Appearance
Clone
Colors to rotate between with animation when a payment is made. One color per line.
Confirm new password
Confirm password
Connection string
Consider the invoice paid even if the paid amount is % less than expected
Consider the invoice settled when the payment transaction
Contact URL
Contact Us
Contribution Perks Template
Count all invoices created on the store as part of the goal
Create
Create a new app
Create a new dictionary
Create Account
Create Form
Create Invoice
Create Pull Payment
Create Request
Create Store
Create Webhook
Create your account
Crowdfund
Currency
Current password
Custom CSS
Custom HTML title to display on Checkout page
Custom sound file for successful payment
Custom Theme Extension Type
Custom Theme File
Dashboard
Default currency
Default language on checkout
Default payment method on checkout
Default role for users on a new store
Delete this store
Derivation scheme
Derivation scheme format
Description
Description template of the lightning invoice
Destination Address
Dictionaries
Dictionaries enable you to translate the BTCPay Server backend into different languages.
Dictionary
Disable public user registration
Disable stores from using the server's email settings as backup
Discourage search engines from indexing this site
Display app on website root
Display contribution ranking
Display contribution value
Display item selection for keypad
Display Lightning payment amounts in Satoshis
Display the category list
Display the search bar
Display Title
Disqus Shortname
Do not allow additional contributions after target has been reached
Does not extend a BTCPay Server theme, fully custom
Domain
Domain name
Don't create UTXO change
Email
Email address
Email confirmation required
Email confirmed?
Emails
Enable background animations on new payments
Enable Disqus Comments
Enable experimental features
Enable LNURL
Enable Payjoin/P2EP
Enable public receipt page for settled invoices
Enable public user registration
Enable sounds on checkout page
Enable sounds on new payments
Enable tips
End date
Error
Expiration Date
Export
Extends the BTCPay Server Dark theme
Extends the BTCPay Server Light theme
Fallback
Featured Image URL
Fee rate (sat/vB)
Files
Forgot password?
Form configuration (JSON)
Forms
Gap limit
Generate
Generate API Key
Generate Key
Google Cloud Storage
GRPC SSL Cipher suite (GRPC_SSL_CIPHER_SUITES)
Hide Sensitive Info
If a translation isnt available in the new dictionary, it will be searched in the fallback.
Image
Invoice currency
Invoice expires if the full amount has not been paid after
Invoice metadata
Invoices
Is administrator?
Is signing key
Item Description
Keypad
Lightning node (LNURL Auth)
LNURL Classic Mode
Local File System
Log in
Login Codes
Logo
Logout
Logs
Maintenance
Make Crowdfund Public
Manage Account
Manage Plugins
Master fingerprint
Max sats
Memo
Metadata
Min sats
Minimum acceptable expiration time for BOLT11 for refunds
Name
New password
Next
Non-admins can access the User Creation API Endpoint
Non-admins can create Hot Wallets for their Store
Non-admins can import Hot Wallets for their Store
Non-admins can use the Internal Lightning Node for their Store
Non-admins cannot access the User Creation API Endpoint
Notification Email
Notification URL
Notifications
Only enable the payment method after user explicitly chooses it
Optional seed passphrase
Order Id
Override the block explorers used
Pair to
Password
Password (leave blank to generate invite-link)
Pay Button
PayJoin BIP21
Payment
Payment invalid if transactions fails to confirm after invoice expiration
Payments
Payout Methods
Payout Processors
Payouts
Plugin server
Plugins
Point of Sale
Point of Sale Style
Policies
Preferred Price Source
Print display
Product list
Product list with cart
Profile Picture
PSBT content
PSBT to combine with
Public Key
Pull Payments
Rate Rules
Rates
Recommended fee confirmation target blocks
Recovery Code
Redirect invoice to redirect url automatically after paid
Redirect URL
Regenerate code
Register
Remember me
Remember this machine
Remove
Reporting
Request contributor data on checkout
Request customer data on checkout
Request Pairing
Requests
Reset goal every
REST Uri
Role
Roles
Root fingerprint
Save
Scope
Search engines can index this site
Security device (FIDO2)
Select
Select the Default Currency during Store Creation
Select the payout method used for refund
Send test webhook
Server Name
Server Settings
Services
Set Password
Settings
Shop Name
Shopify
Show "Pay in wallet" button
Show a timer minutes before invoice expiration
Show plugins in pre-release
Show recommended fee
Show the payment list in the public receipt page
Show the QR code of the receipt in the public receipt page
Show the store header
Sign in
Sort contribution perks by popularity
Sounds to play when a payment is made. One sound per line
Specify the amount and currency for the refund
Start date
Starting index
Store
Store Id
Store Name
Store Settings
Store Website
Submit
Subtract fees from amount
Support URL
Supported Transaction Currencies
Target Amount
Test Email
Text to display in the tip input
Text to display on buttons allowing the user to enter a custom amount
Text to display on each button for items with a specific price
Theme
Tip percentage amounts (comma separated)
Translations
Two-Factor Authentication
Unarchive this store
Unify on-chain and lightning payment URL/QR code
Update Password
Update Webhook
Upload PSBT from file
Url of the Dynamic DNS service you are using
Use custom theme
Use SSL
User can input custom amount
User can input discount in %
Users
UTXOs to spend from
Verification Code
Wallet file
Wallet file content
Wallets
Webhooks
Welcome to {0}
Your dynamic DNS hostname
{
"... on every payment": "",
"... only if the customer makes more than one payment for the invoice": "",
"A given currency pair match the most specific rule. If two rules are matching and are as specific, the first rule will be chosen.": "",
"Access Tokens": "",
"Account": "",
"Account key": "",
"Account key path": "",
"Add additional fee (network fee) to invoice …": "",
"Add Address": "",
"Add Exchange Rate Spread": "",
"Add hop hints for private channels to the Lightning invoice": "",
"Add Role": "",
"Add Service": "",
"Add User": "",
"Add Webhook": "",
"Additional Actions": "",
"Admin API access token": "",
"Admin must approve new users": "",
"Administrator": "",
"Advanced rate rule scripting": "",
"Allow anyone to create invoice": "",
"Allow form for public use": "",
"Allow payee to create invoices with custom amounts": "",
"Allow payee to pass a comment": "",
"Allow Stores use the Server's SMTP email settings as their default": "",
"Always include non-witness UTXO if available": "",
"Amazon S3": "",
"Amount": "",
"API Key": "",
"API Keys": "",
"App": "",
"App Name": "",
"App Type": "",
"Application": "",
"Apply the brand color to the store's backend as well": "",
"Approve": "",
"Archive this store": "",
"At Least One": "",
"At Least Ten": "",
"Authenticator code": "",
"Auto-detect language on checkout": "",
"Automatically approve claims": "",
"Available Payment Methods": "",
"Azure Blob Storage": "",
"Backend's language": "",
"Batch size": "",
"BIP39 Seed (12/24 word mnemonic phrase) or HD private key (xprv...)": "",
"blocks": "",
"Brand Color": "",
"Branding": "",
"But now, what if you want to support <code>DOGE</code>? The problem with <code>DOGE</code> is that most exchange do not have any pair for it. But <code>bitpay</code> has a <code>DOGE_BTC</code> pair. <br />\r\n Luckily, the rule engine allow you to reference rules:": "",
"Buyer Email": "",
"Callback Notification URL": "",
"Can use hot wallet": "",
"Can use RPC import": "",
"Celebrate payment with confetti": "",
"Check releases on GitHub and notify when new BTCPay Server version is available": "",
"Checkout Appearance": "",
"Choose your import method": "",
"Choose your wallet option": "",
"Clone": "",
"Coingecko integration": "",
"Colors to rotate between with animation when a payment is made. One color per line.": "",
"Confirm new password": "",
"Confirm password": "",
"Connect an existing wallet": "",
"Connect hardware&nbsp;wallet": "",
"Connection string": "",
"Consider the invoice paid even if the paid amount is … % less than expected": "",
"Consider the invoice settled when the payment transaction …": "",
"Contact URL": "",
"Contact Us": "",
"Contribution Perks Template": "",
"Count all invoices created on the store as part of the goal": "",
"Create": "",
"Create a new app": "",
"Create a new wallet": "",
"Create Account": "",
"Create Form": "",
"Create Invoice": "",
"Create Pull Payment": "",
"Create Request": "",
"Create Store": "",
"Create Webhook": "",
"Create your account": "",
"Crowdfund": "",
"Currency": "",
"Current password": "",
"Custom": "",
"Custom CSS": "",
"Custom HTML title to display on Checkout page": "",
"Custom sound file for successful payment": "",
"Custom Theme Extension Type": "",
"Custom Theme File": "",
"Dashboard": "",
"days": "",
"Default currency": "",
"Default Currency Pairs": "",
"Default language on checkout": "",
"Default payment method on checkout": "",
"Default role for users on a new store": "",
"Delete this store": "",
"Derivation scheme": "",
"Derivation scheme format": "",
"Description": "",
"Description template of the lightning invoice": "",
"Destination Address": "",
"Dictionaries": "",
"Dictionaries enable you to translate the BTCPay Server backend into different languages.": "",
"Dictionary": "",
"Direct integration": "",
"Disable public user registration": "",
"Disable stores from using the server's email settings as backup": "",
"Discourage search engines from indexing this site": "",
"Display app on website root": "",
"Display contribution ranking": "",
"Display contribution value": "",
"Display item selection for keypad": "",
"Display Lightning payment amounts in Satoshis": "",
"Display the category list": "",
"Display the search bar": "",
"Display Title": "",
"Disqus Shortname": "",
"Do not allow additional contributions after target has been reached": "",
"Does not extend a BTCPay Server theme, fully custom": "",
"Domain": "",
"Domain name": "",
"Don't create UTXO change": "",
"Email": "",
"Email address": "",
"Email confirmation required": "",
"Email confirmed?": "",
"Emails": "",
"Enable background animations on new payments": "",
"Enable Disqus Comments": "",
"Enable experimental features": "",
"Enable LNURL": "",
"Enable Payjoin/P2EP": "",
"Enable public receipt page for settled invoices": "",
"Enable public user registration": "",
"Enable sounds on checkout page": "",
"Enable sounds on new payments": "",
"Enable tips": "",
"End date": "",
"Enter extended public key": "",
"Enter wallet seed": "",
"Error": "",
"Expiration Date": "",
"Export": "",
"Extends the BTCPay Server Dark theme": "",
"Extends the BTCPay Server Light theme": "",
"Fallback": "",
"Featured Image URL": "",
"Fee rate (sat/vB)": "",
"Fee will be shown for BTC and LTC onchain payments only.": "",
"Files": "",
"Forgot password?": "",
"Form configuration (JSON)": "",
"Forms": "",
"Gap limit": "",
"Generate": "",
"Generate {0} Wallet": "",
"Generate a brand-new wallet to use": "",
"Generate API Key": "",
"Generate Key": "",
"Google Cloud Storage": "",
"GRPC SSL Cipher suite (GRPC_SSL_CIPHER_SUITES)": "",
"Has at least 1 confirmation": "",
"Has at least 2 confirmations": "",
"Has at least 6 confirmations": "",
"Hide Sensitive Info": "",
"Hot wallet": "",
"However, <code>kraken</code> does not support the <code>BTC_CAD</code> pair. For this reason you can add a rule mapping all <code>X_CAD</code> to <code>ndax</code>, a Canadian exchange.": "",
"However, explicitely setting specific pairs like this can be a bit difficult. Instead, you can define a rule <code>X_X</code> which will match any currency pair. The following example will use <code>kraken</code> for getting the rate of any currency pair.": "",
"I don't have a wallet": "",
"I have a wallet": "",
"If a translation isnt available in the new dictionary, it will be searched in the fallback.": "",
"Image": "",
"Import {0} Wallet": "",
"Import an existing hardware or software wallet": "",
"Import wallet file": "",
"Import your public keys using our Vault application": "",
"Input the key string manually": "",
"Invitation URL": "",
"Invoice currency": "",
"Invoice expires if the full amount has not been paid after …": "",
"Invoice metadata": "",
"Invoices": "",
"Is administrator?": "",
"Is signing key": "",
"Is unconfirmed": "",
"It is worth noting that the inverses of those pairs are automatically supported as well.<br />\r\n It means that the rule <code>USD_DOGE = 1 / DOGE_USD</code> implicitely exists.": "",
"Item Description": "",
"Keypad": "",
"Let's get started": "",
"Lightning node (LNURL Auth)": "",
"LNURL Classic Mode": "",
"Local File System": "",
"Log in": "",
"Login Codes": "",
"Logo": "",
"Logout": "",
"Logs": "",
"Maintenance": "",
"Make Crowdfund Public": "",
"Manage Account": "",
"Manage Plugins": "",
"Master fingerprint": "",
"Max sats": "",
"Memo": "",
"Metadata": "",
"Min sats": "",
"Minimum acceptable expiration time for BOLT11 for refunds": "",
"Never add network fee": "",
"New password": "",
"Next": "",
"Non-admins can access the User Creation API Endpoint": "",
"Non-admins can create Hot Wallets for their Store": "",
"Non-admins can import Hot Wallets for their Store": "",
"Non-admins can use the Internal Lightning Node for their Store": "",
"Non-admins cannot access the User Creation API Endpoint": "",
"Not recommended": "",
"Notification Email": "",
"Notification URL": "",
"Notifications": "",
"Only enable the payment method after user explicitly chooses it": "",
"Optional seed passphrase": "",
"Order Id": "",
"Override the block explorers used": "",
"Pair to": "",
"Password": "",
"Password (leave blank to generate invite-link)": "",
"Pay Button": "",
"PayJoin BIP21": "",
"Payment": "",
"Payment invalid if transactions fails to confirm … after invoice expiration": "",
"Payments": "",
"Payout Methods": "",
"Payout Processors": "",
"Payouts": "",
"Please enable JavaScript for this option to be available": "",
"Please note that creating a hot wallet is not supported by this instance for non administrators.": "",
"Plugin server": "",
"Plugins": "",
"Point of Sale": "",
"Point of Sale Style": "",
"Policies": "",
"Preferred Price Source": "",
"Print display": "",
"Product list": "",
"Product list with cart": "",
"Profile Picture": "",
"Provide the 12 or 24 word recovery seed": "",
"PSBT content": "",
"PSBT to combine with…": "",
"Public Key": "",
"Pull Payments": "",
"Rate Rules": "",
"Rate script allows you to express precisely how you want to calculate rates for currency pairs.": "",
"Rates": "",
"Recommended": "",
"Recommended fee confirmation target blocks": "",
"Recovery Code": "",
"Redirect invoice to redirect url automatically after paid": "",
"Redirect URL": "",
"Register": "",
"Remember me": "",
"Remember this machine": "",
"Remove": "",
"Reporting": "",
"Request contributor data on checkout": "",
"Request customer data on checkout": "",
"Request Pairing": "",
"Requests": "",
"Required Confirmations": "",
"Reset goal every": "",
"Reset Password": "",
"REST Uri": "",
"Role": "",
"Roles": "",
"Root fingerprint": "",
"Save": "",
"Scan wallet QR code": "",
"Scope": "",
"Scripting": "",
"Search engines can index this site": "",
"Security device (FIDO2)": "",
"Select": "",
"Select the Default Currency during Store Creation": "",
"Select the payout method used for refund": "",
"Send invitation email": "",
"Send test webhook": "",
"Server Name": "",
"Server Settings": "",
"Services": "",
"Set Password": "",
"Set to default settings": "",
"Settings": "",
"Setup {0} Wallet": "",
"Shop Name": "",
"Shopify": "",
"Show \"Pay in wallet\" button": "",
"Show a timer … minutes before invoice expiration": "",
"Show plugins in pre-release": "",
"Show recommended fee": "",
"Show the payment list in the public receipt page": "",
"Show the QR code of the receipt in the public receipt page": "",
"Show the store header": "",
"Sign in": "",
"Sort contribution perks by popularity": "",
"Sounds to play when a payment is made. One sound per line": "",
"Specify the amount and currency for the refund": "",
"Start date": "",
"Starting index": "",
"Store": "",
"Store Id": "",
"Store Name": "",
"Store Settings": "",
"Store Speed Policy": "",
"Store Website": "",
"Submit": "",
"Subtract fees from amount": "",
"Support URL": "",
"Supported by BlueWallet, Cobo Vault, Passport and Specter DIY": "",
"Supported Transaction Currencies": "",
"Target Amount": "",
"Test Email": "",
"Test Results:": "",
"Testing": "",
"Text to display in the tip input": "",
"Text to display on buttons allowing the user to enter a custom amount": "",
"Text to display on each button for items with a specific price": "",
"The following methods assume that you already have an existing&nbsp;wallet created and backed up.": "",
"The script language is composed of several rules composed of a currency pair and a mathematic expression.\r\n The example below will use <code>kraken</code> for both <code>LTC_USD</code> and <code>BTC_USD</code> pairs.": "",
"Theme": "",
"Tip percentage amounts (comma separated)": "",
"Translations": "",
"Two-Factor Authentication": "",
"Unarchive this store": "",
"Unify on-chain and lightning payment URL/QR code": "",
"Update Password": "",
"Update Webhook": "",
"Upload a file exported from your wallet": "",
"Upload PSBT from file…": "",
"Url of the Dynamic DNS service you are using": "",
"Use custom theme": "",
"Use SSL": "",
"User can input custom amount": "",
"User can input discount in %": "",
"Users": "",
"UTXOs to spend from": "",
"Verification Code": "",
"View-Only Wallet File": "",
"Wallet file": "",
"Wallet file content": "",
"Wallet Keys File": "",
"Wallet Password": "",
"Wallet's private key is erased from the server. Higher security. To spend, you have to manually input the private key or import it into an external wallet.": "",
"Wallet's private key is stored on the server. Spending the funds you received is convenient. To minimize the risk of theft, regularly withdraw funds to a different wallet.": "",
"Wallets": "",
"Watch-only wallet": "",
"Webhooks": "",
"Welcome to {0}": "",
"With <code>DOGE_USD</code> will be expanded to <code>bitpay(DOGE_BTC) * kraken(BTC_USD)</code>. And <code>DOGE_CAD</code> will be expanded to <code>bitpay(DOGE_BTC) * ndax(BTC_CAD)</code>. <br />\r\n However, we advise you to write it that way to increase coverage so that <code>DOGE_BTC</code> is also supported:": "",
"You really should not type your seed into a device that is connected to the internet.": "",
"Your dynamic DNS hostname": "",
"Zero Confirmation": ""
}
""";
Default = Translations.CreateFromText(knownTranslations);
Default = Translations.CreateFromJson(knownTranslations);
Default = new Translations(new KeyValuePair<string, string>[]
{
// You can add additional hard coded default here

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