diff --git a/BTCPayServer.Client/Models/CreateInvoiceRequest.cs b/BTCPayServer.Client/Models/CreateInvoiceRequest.cs index e017b9b20..82f1f143e 100644 --- a/BTCPayServer.Client/Models/CreateInvoiceRequest.cs +++ b/BTCPayServer.Client/Models/CreateInvoiceRequest.cs @@ -31,9 +31,9 @@ namespace BTCPayServer.Client.Models public TimeSpan? Monitoring { get; set; } public double? PaymentTolerance { get; set; } - [JsonProperty("redirectURL")] public string RedirectURL { get; set; } + public string DefaultLanguage { get; set; } } } } diff --git a/BTCPayServer.Client/Models/InvoiceData.cs b/BTCPayServer.Client/Models/InvoiceData.cs index 3fffb15fc..95ba3e55e 100644 --- a/BTCPayServer.Client/Models/InvoiceData.cs +++ b/BTCPayServer.Client/Models/InvoiceData.cs @@ -7,6 +7,7 @@ namespace BTCPayServer.Client.Models public class InvoiceData : CreateInvoiceRequest { public string Id { get; set; } + public string CheckoutLink { get; set; } [JsonConverter(typeof(StringEnumConverter))] public InvoiceStatus Status { get; set; } [JsonConverter(typeof(StringEnumConverter))] diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index f993424e4..360bab342 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -10,6 +10,7 @@ using BTCPayServer.Controllers; using BTCPayServer.Events; using BTCPayServer.JsonConverters; using BTCPayServer.Lightning; +using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Services; using BTCPayServer.Services.Invoices; using BTCPayServer.Tests.Logging; @@ -244,19 +245,19 @@ namespace BTCPayServer.Tests Password = "afewfoiewiou", IsAdministrator = true })); - + // If we set DisableNonAdminCreateUserApi = true, it should always fail to create a user unless you are an admin - await settings.UpdateSetting(new PoliciesSettings() { LockSubscription = false, DisableNonAdminCreateUserApi = true}); + await settings.UpdateSetting(new PoliciesSettings() { LockSubscription = false, DisableNonAdminCreateUserApi = true }); await AssertHttpError(403, async () => await unauthClient.CreateUser( - new CreateApplicationUserRequest() {Email = "test9@gmail.com", Password = "afewfoiewiou"})); + new CreateApplicationUserRequest() { Email = "test9@gmail.com", Password = "afewfoiewiou" })); await AssertHttpError(403, async () => await user1Client.CreateUser( - new CreateApplicationUserRequest() {Email = "test9@gmail.com", Password = "afewfoiewiou"})); + new CreateApplicationUserRequest() { Email = "test9@gmail.com", Password = "afewfoiewiou" })); await adminClient.CreateUser( - new CreateApplicationUserRequest() {Email = "test9@gmail.com", Password = "afewfoiewiou"}); + new CreateApplicationUserRequest() { Email = "test9@gmail.com", Password = "afewfoiewiou" }); } } @@ -964,7 +965,7 @@ namespace BTCPayServer.Tests var paymentMethod = paymentMethods.First(); Assert.Equal("BTC", paymentMethod.PaymentMethod); Assert.Empty(paymentMethod.Payments); - + //update invoice = await viewOnly.GetInvoice(user.StoreId, newInvoice.Id); @@ -1025,6 +1026,42 @@ namespace BTCPayServer.Tests Assert.NotNull(await client.GetWebhookDelivery(evt.StoreId, evt.WebhookId, evt.DeliveryId)); } } + + + newInvoice = await client.CreateInvoice(user.StoreId, + new CreateInvoiceRequest() + { + Currency = "USD", + Amount = 1, + Checkout = new CreateInvoiceRequest.CheckoutOptions() + { + DefaultLanguage = "it-it ", + RedirectURL = "http://toto.com/lol" + } + }); + Assert.EndsWith($"/i/{newInvoice.Id}", newInvoice.CheckoutLink); + var controller = tester.PayTester.GetController(user.UserId, user.StoreId); + var model = (PaymentModel)((ViewResult)await controller.Checkout(newInvoice.Id)).Model; + Assert.Equal("it-IT", model.DefaultLang); + Assert.Equal("http://toto.com/lol", model.MerchantRefLink); + + var langs = tester.PayTester.GetService(); + foreach (var match in new[] { "it", "it-IT", "it-LOL" }) + { + Assert.Equal("it-IT", langs.FindBestMatch(match).Code); + } + foreach (var match in new[] { "pt-BR" }) + { + Assert.Equal("pt-BR", langs.FindBestMatch(match).Code); + } + foreach (var match in new[] { "en", "en-US" }) + { + Assert.Equal("en", langs.FindBestMatch(match).Code); + } + foreach (var match in new[] { "pt", "pt-pt", "pt-PT" }) + { + Assert.Equal("pt-PT", langs.FindBestMatch(match).Code); + } } } diff --git a/BTCPayServer/Controllers/GreenField/InvoiceController.cs b/BTCPayServer/Controllers/GreenField/InvoiceController.cs index e94e5d327..bcd303718 100644 --- a/BTCPayServer/Controllers/GreenField/InvoiceController.cs +++ b/BTCPayServer/Controllers/GreenField/InvoiceController.cs @@ -7,11 +7,13 @@ using BTCPayServer.Client.Models; using BTCPayServer.Models.InvoicingModels; using BTCPayServer.Payments; using BTCPayServer.Security; +using BTCPayServer.Services; using BTCPayServer.Services.Invoices; using BTCPayServer.Validation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore.Metadata.Conventions; using NBitcoin; using NBitpayClient; @@ -29,11 +31,16 @@ namespace BTCPayServer.Controllers.GreenField { private readonly InvoiceController _invoiceController; private readonly InvoiceRepository _invoiceRepository; + private readonly LinkGenerator _linkGenerator; - public GreenFieldInvoiceController(InvoiceController invoiceController, InvoiceRepository invoiceRepository) + public LanguageService LanguageService { get; } + + public GreenFieldInvoiceController(InvoiceController invoiceController, InvoiceRepository invoiceRepository, LinkGenerator linkGenerator, LanguageService languageService) { _invoiceController = invoiceController; _invoiceRepository = invoiceRepository; + _linkGenerator = linkGenerator; + LanguageService = languageService; } [Authorize(Policy = Policies.CanViewInvoices, @@ -139,6 +146,21 @@ namespace BTCPayServer.Controllers.GreenField "PaymentTolerance can only be between 0 and 100 percent", this); } + if (request.Checkout.DefaultLanguage != null) + { + var lang = LanguageService.FindBestMatch(request.Checkout.DefaultLanguage); + if (lang == null) + { + request.AddModelError(invoiceRequest => invoiceRequest.Checkout.DefaultLanguage, + "The requested defaultLang does not exists, Browse the ~/misc/lang page of your BTCPay Server instance to see the list of supported languages.", this); + } + else + { + // Ensure this is good case + request.Checkout.DefaultLanguage = lang.Code; + } + } + if (!ModelState.IsValid) return this.CreateValidationError(ModelState); @@ -287,6 +309,7 @@ namespace BTCPayServer.Controllers.GreenField CreatedTime = entity.InvoiceTime, Amount = entity.Price, Id = entity.Id, + CheckoutLink = _linkGenerator.CheckoutLink(entity.Id, Request.Scheme, Request.Host, Request.PathBase), Status = entity.Status.ToModernStatus(), AdditionalStatus = entity.ExceptionStatus, Currency = entity.Currency, @@ -298,7 +321,8 @@ namespace BTCPayServer.Controllers.GreenField PaymentTolerance = entity.PaymentTolerance, PaymentMethods = entity.GetPaymentMethods().Select(method => method.GetId().ToStringNormalized()).ToArray(), - SpeedPolicy = entity.SpeedPolicy + SpeedPolicy = entity.SpeedPolicy, + DefaultLanguage = entity.DefaultLanguage } }; } diff --git a/BTCPayServer/Controllers/HomeController.cs b/BTCPayServer/Controllers/HomeController.cs index fe2c39b61..476c10d2e 100644 --- a/BTCPayServer/Controllers/HomeController.cs +++ b/BTCPayServer/Controllers/HomeController.cs @@ -11,6 +11,7 @@ using BTCPayServer.HostedServices; using BTCPayServer.Models; using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Security; +using BTCPayServer.Services; using BTCPayServer.Services.Apps; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; @@ -31,15 +32,18 @@ namespace BTCPayServer.Controllers private readonly IFileProvider _fileProvider; public IHttpClientFactory HttpClientFactory { get; } + public LanguageService LanguageService { get; } SignInManager SignInManager { get; } public HomeController(IHttpClientFactory httpClientFactory, CssThemeManager cachedServerSettings, IWebHostEnvironment webHostEnvironment, + LanguageService languageService, SignInManager signInManager) { HttpClientFactory = httpClientFactory; _cachedServerSettings = cachedServerSettings; + LanguageService = languageService; _fileProvider = webHostEnvironment.WebRootFileProvider; SignInManager = signInManager; } @@ -116,6 +120,12 @@ namespace BTCPayServer.Controllers { return View(new BitpayTranslatorViewModel()); } + [Route("misc/lang")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public IActionResult Languages() + { + return Json(LanguageService.GetLanguages(), new JsonSerializerSettings() { Formatting = Formatting.Indented }); + } [Route("swagger/v1/swagger.json")] [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie + "," + AuthenticationSchemes.Greenfield)] diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index 8d06dbb22..72b34d5ff 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -433,14 +433,14 @@ namespace BTCPayServer.Controllers [XFrameOptionsAttribute(null)] [ReferrerPolicyAttribute("origin")] public async Task Checkout(string invoiceId, string id = null, string paymentMethodId = null, - [FromQuery] string view = null) + [FromQuery] string view = null, [FromQuery] string lang = null) { //Keep compatibility with Bitpay invoiceId = invoiceId ?? id; id = invoiceId; // - var model = await GetInvoiceModel(invoiceId, paymentMethodId == null ? null : PaymentMethodId.Parse(paymentMethodId)); + var model = await GetInvoiceModel(invoiceId, paymentMethodId == null ? null : PaymentMethodId.Parse(paymentMethodId), lang); if (model == null) return NotFound(); @@ -465,21 +465,21 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("invoice-noscript")] - public async Task CheckoutNoScript(string invoiceId, string id = null, string paymentMethodId = null) + public async Task CheckoutNoScript(string invoiceId, string id = null, string paymentMethodId = null, [FromQuery] string lang = null) { //Keep compatibility with Bitpay invoiceId = invoiceId ?? id; id = invoiceId; // - var model = await GetInvoiceModel(invoiceId, paymentMethodId == null ? null : PaymentMethodId.Parse(paymentMethodId)); + var model = await GetInvoiceModel(invoiceId, paymentMethodId == null ? null : PaymentMethodId.Parse(paymentMethodId), lang); if (model == null) return NotFound(); return View(model); } - private async Task GetInvoiceModel(string invoiceId, PaymentMethodId paymentMethodId) + private async Task GetInvoiceModel(string invoiceId, PaymentMethodId paymentMethodId, string lang) { var invoice = await _InvoiceRepository.GetInvoice(invoiceId); if (invoice == null) @@ -534,7 +534,7 @@ namespace BTCPayServer.Controllers RootPath = this.Request.PathBase.Value.WithTrailingSlash(), OrderId = invoice.Metadata.OrderId, InvoiceId = invoice.Id, - DefaultLang = storeBlob.DefaultLang ?? "en", + DefaultLang = lang ?? invoice.DefaultLanguage ?? storeBlob.DefaultLang ?? "en", CustomCSSLink = storeBlob.CustomCSS, CustomLogoLink = storeBlob.CustomLogo, HtmlTitle = storeBlob.HtmlTitle ?? "BTCPay Invoice", @@ -619,9 +619,9 @@ namespace BTCPayServer.Controllers [Route("invoice/{invoiceId}/status")] [Route("invoice/{invoiceId}/{paymentMethodId}/status")] [Route("invoice/status")] - public async Task GetStatus(string invoiceId, string paymentMethodId = null) + public async Task GetStatus(string invoiceId, string paymentMethodId = null, [FromQuery] string lang = null) { - var model = await GetInvoiceModel(invoiceId, paymentMethodId == null ? null : PaymentMethodId.Parse(paymentMethodId)); + var model = await GetInvoiceModel(invoiceId, paymentMethodId == null ? null : PaymentMethodId.Parse(paymentMethodId), lang); if (model == null) return NotFound(); return Json(model); diff --git a/BTCPayServer/Controllers/InvoiceController.cs b/BTCPayServer/Controllers/InvoiceController.cs index eed7bbd7a..386c1d5b4 100644 --- a/BTCPayServer/Controllers/InvoiceController.cs +++ b/BTCPayServer/Controllers/InvoiceController.cs @@ -172,6 +172,7 @@ namespace BTCPayServer.Controllers entity.Currency = invoice.Currency; entity.Price = invoice.Amount; entity.SpeedPolicy = invoice.Checkout.SpeedPolicy ?? store.SpeedPolicy; + entity.DefaultLanguage = invoice.Checkout.DefaultLanguage; IPaymentFilter excludeFilter = null; if (invoice.Checkout.PaymentMethods != null) { diff --git a/BTCPayServer/Extensions/UrlHelperExtensions.cs b/BTCPayServer/Extensions/UrlHelperExtensions.cs index f3b6d3795..f0d8fc08a 100644 --- a/BTCPayServer/Extensions/UrlHelperExtensions.cs +++ b/BTCPayServer/Extensions/UrlHelperExtensions.cs @@ -54,6 +54,15 @@ namespace Microsoft.AspNetCore.Mvc scheme, host, pathbase); } + public static string CheckoutLink(this LinkGenerator urlHelper, string invoiceId, string scheme, HostString host, string pathbase) + { + return urlHelper.GetUriByAction( + action: nameof(InvoiceController.Checkout), + controller: "Invoice", + values: new { invoiceId = invoiceId }, + scheme, host, pathbase); + } + public static string PayoutLink(this LinkGenerator urlHelper, string walletId,string pullPaymentId, string scheme, HostString host, string pathbase) { return urlHelper.GetUriByAction( diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 40a870abb..6df2cdaf7 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -132,6 +132,7 @@ namespace BTCPayServer.Services.Invoices public string StoreId { get; set; } public SpeedPolicy SpeedPolicy { get; set; } + public string DefaultLanguage { get; set; } [Obsolete("Use GetPaymentMethod(network) instead")] public decimal Rate { get; set; } public DateTimeOffset InvoiceTime { get; set; } diff --git a/BTCPayServer/Services/LanguageService.cs b/BTCPayServer/Services/LanguageService.cs index ee5800722..a72959ce5 100644 --- a/BTCPayServer/Services/LanguageService.cs +++ b/BTCPayServer/Services/LanguageService.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.IO; +using System.Linq; using Microsoft.AspNetCore.Hosting; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -46,5 +48,31 @@ namespace BTCPayServer.Services { return _languages; } + + public Language FindBestMatch(string defaultLang) + { + if (defaultLang is null) + return null; + defaultLang = defaultLang.Trim(); + if (defaultLang.Length < 2) + return null; + var split = defaultLang.Split('-', StringSplitOptions.RemoveEmptyEntries); + if (split.Length != 1 && split.Length != 2) + return null; + var lang = split[0]; + var country = split.Length == 2 ? split[1] : split[0].ToUpperInvariant(); + + var langStart = lang + "-"; + var langMatches = GetLanguages() + .Where(l => l.Code.Equals(lang, StringComparison.OrdinalIgnoreCase) || + l.Code.StartsWith(langStart, StringComparison.OrdinalIgnoreCase)); + + var countryMatches = langMatches; + var countryEnd = "-" + country; + countryMatches = + countryMatches + .Where(l => l.Code.EndsWith(countryEnd, StringComparison.OrdinalIgnoreCase)); + return countryMatches.FirstOrDefault() ?? langMatches.FirstOrDefault(); + } } } diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json index 399881293..fc41fc8cd 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json @@ -567,6 +567,10 @@ "type": "string", "description": "The identifier of the invoice" }, + "checkoutLink": { + "type": "string", + "description": "The link to the checkout page, where you can redirect the customer" + }, "createdTime": { "type": "number", "format": "int64", @@ -667,6 +671,11 @@ "type": "string", "nullable": true, "description": "When the customer paid the invoice, the URL where the customer will be redirected when clicking on the `return to store` button. You can use placeholders `{InvoiceId}` or `{OrderId}` in the URL, BTCPay Server will replace those with this invoice `id` or `metadata.orderId` respectively." + }, + "defaultLanguage": { + "type": "string", + "nullable": true, + "description": "The language code (eg. en-US, en, fr-FR...) of the language presented to your customer in the checkout page. BTCPay Server tries to match the best language available. If null or not set, will fallback on the store's default language. Browse the [/misc/lang](/misc/lang) page of your BTCPay Server instance to see the list of supported languages." } } },